microsoft / microsoft/aspire

WithTls should redirect HTTP→HTTPS by default (port 80 is only for ACME)

Open
#17,158 1 comment 0 reactions 0 assignees View on GitHub
area-deployment kubernetes triage:bot-seen triage:needs-human
Dominant language
C#
Stars
6.3k
Forks
991
Avg merge
2d 15h
Merged PRs (30d)
196

Description

## Summary

Make `WithTls(issuer)` on a Gateway API gateway own the entire TLS posture for that gateway, not just TLS termination. Out of the box, calling `.WithTls(letsencrypt)` should give you:

1. The HTTPS listener with the cert-manager-issued certificate (today's behavior).
2. A `301 Moved Permanently` redirect from port 80 to HTTPS (new).
3. `Strict-Transport-Security: max-age=31536000` on HTTPS responses (new).

Port 80 on a TLS'd Aspire gateway exists for one legitimate reason — ACME HTTP-01 challenges — and every other request that lands there should be transparently upgraded. HSTS is intrinsic to that decision: if you've chosen TLS for this hostname, the gateway should tell returning browsers to stop trying HTTP.

This is a "pit of success" change: the safe behavior becomes the default, with explicit opt-outs for the rare case where a user genuinely needs to deviate.

## Current behavior

After the `WithLoadBalancer` / `WithGateway` / `WithTls` / `AddCertManager` walkthrough that shipped in `Aspire.Hosting.Azure.Kubernetes` 13.4, running it end-to-end on AKS produces:

```
$ curl -i http:///
HTTP/1.1 404 Not Found

$ curl -i https:///
HTTP/1.1 200 OK
```

The 404 happens because:

1. `WithTls` adds an HTTPS listener with the gateway's FQDN as the listener hostname (patched in by the `tls-fqdn-discovery` pipeline step).
2. The HTTP listener has no hostname.
3. The `storefront-route` HTTPRoute has no hostnames, so per Gateway API binding rules it binds preferentially to the more specific (hostname-matched) HTTPS listener.
4. The HTTP listener ends up with no matching route for that hostname → AGC returns 404.

This is the actual reproduced behavior from a live deploy to `westus3` (`Standard_D2as_v5`, AGC ALB, cert-manager v1.20.2, Let's Encrypt staging issuer). I initially tried to handle this in docs by adding a troubleshooting subsection ("this is expected, use HTTPS") in microsoft/aspire.dev#973 — but the right fix is product, not docs.

## Proposed API

Single API entry point. All TLS-posture knobs live on the same options object, so the user makes one decision in one place instead of remembering to call paired methods in the right order.

### C# — configure-delegate

```csharp
// Default: TLS + 301 redirect on :80 + HSTS max-age=1y
gw.WithTls(letsencrypt);

// Disable the redirect (rare; for hostnames that legitimately need plaintext compatibility)
gw.WithTls(letsencrypt, options =>
{
options.RedirectHttp = false;
});

// Disable HSTS entirely
gw.WithTls(letsencrypt, options =>
{
options.Hsts.Enabled = false;
});

// Full commitment — preload, all subdomains, 1 year (default max-age)
gw.WithTls(letsencrypt, options =>
{
options.Hsts.IncludeSubDomains = true;
options.Hsts.Preload = true;
});

// Tune max-age (e.g. for a phased rollout)
gw.WithTls(letsencrypt, options =>
{
options.Hsts.MaxAge = TimeSpan.FromDays(30);
});
```

Backing types:

```csharp
public sealed class TlsOptions
{
/// Emit a 301 redirect from the HTTP listener to HTTPS. Defaults to true.
public bool RedirectHttp { get; set; } = true;

public HstsOptions Hsts { get; } = new();
}

public sealed class HstsOptions
{
/// Emit Strict-Transport-Security on HTTPS responses. Defaults to true.
public bool Enabled { get; set; } = true;

/// Defaults to 365 days.
public TimeSpan MaxAge { get; set; } = TimeSpan.FromDays(365);

/// Apply HSTS to all subdomains. Defaults to false.
public bool IncludeSubDomains { get; set; } = false;

/// Mark the policy as eligible for browser preload lists. Defaults to false.
public bool Preload { get; set; } = false;
}
```

### TypeScript — options object with discriminated union

```typescript
// Default: TLS + 301 redirect on :80 + HSTS max-age=1y
await storefront.withTls(letsencrypt);

// Disable the redirect
await storefront.withTls(letsencrypt, { redirectHttp: false });

// Disable HSTS entirely
await storefront.withTls(letsencrypt, { hsts: false });

// Full commitment
await storefront.withTls(letsencrypt, {
hsts: {
includeSubDomains: true,
preload: true,
},
});

// Tune max-age
await storefront.withTls(letsencrypt, {
hsts: { maxAge: 'P30D' },
});
```

Backing types:

```typescript
interface TlsOptions {
/** Emit a 301 redirect from the HTTP listener to HTTPS. Defaults to true. */
redirectHttp?: boolean;
/** HSTS configuration. Set to false to disable. Defaults to enabled with max-age=1 year. */
hsts?: boolean | HstsOptions;
}

interface HstsOptions {
/** ISO 8601 duration string. Defaults to "P1Y" (one year). */
maxAge?: string;
/** Apply HSTS to all subdomains. Defaults to false. */
includeSubDomains?: boolean;
/** Mark the policy as eligible for browser preload lists. Defaults to false. */
preload?: boolean;
}

interface Gateway {
withTls(issuer: Issuer, options?: TlsOptions): Promise;
}
```

The TS `boolean | HstsOptions` union and the C# `Hsts.Enabled` flag emit identical YAML manifests for the same intent — surface idiom differs, data model is shared.

### Why one method, not `WithTls` + `WithHsts`

TLS termination, the HTTP→HTTPS redirect, and HSTS are three faces of one decision: "this gateway is HTTPS-only." HSTS without TLS is incoherent; the redirect without TLS is pointless. Splitting them into separate fluent methods makes users learn ordering rules, leaves the door open for half-configured gateways, and grows the API surface to express what's really one concept. Future TLS knobs (cipher suite policy, TLS version floor, OCSP stapling, mTLS) land naturally on the same `TlsOptions`.

## Design decisions

### Redirect: 301, not 308

Initial draft of this issue proposed `308 Permanent Redirect` since it preserves HTTP method and body for non-GET requests. A survey of how popular sites actually respond on port 80 showed the convention is overwhelmingly `301`:

| Site | Response |
|---|---|
| github.com | 301 → https://github.com/ |
| google.com | 301 |
| cloudflare.com | 301 |
| stripe.com | 301 |
| aws.amazon.com | 301 |
| azure.microsoft.com | 301 |
| facebook.com | 301 |
| x.com | 301 |
| youtube.com | 301 |
| wikipedia.org | 301 |
| reddit.com | 301 |
| nytimes.com | 301 |
| bbc.co.uk | 301 |
| apple.com | 301 |
| **microsoft.com** | **307** |
| (zero sites tested) | 308 |

The case for matching the convention: clients/CDNs/proxies/HTTP libraries are battle-tested against 301; an Aspire gateway looking like every other gateway on the internet is least surprising. The case for 308 (strict method preservation) is real for API gateways serving POSTs, but the gain is small and the deviation is large. **301 is the default.**

### HSTS `max-age`: 31,536,000 (1 year)

HSTS has no spec-level default — RFC 6797 §6.1.1 requires `max-age`, and browsers reject the header without it. So a value must be chosen. Survey of production sites (sorted by max-age):

| Site | max-age | Years |
|---|---|---|
| facebook.com | 15,552,000 | 0.5 |
| cloudflare.com | 15,780,000 | 0.5 |
| **github.com** | **31,536,000** | **1** |
| **microsoft.com** | **31,536,000** | **1** |
| **azure.microsoft.com** | **31,536,000** | **1** |
| **youtube.com** | **31,536,000** | **1** |
| **reddit.com** | **31,536,000** | **1** |
| aws.amazon.com | 47,304,000 | 1.5 |
| stripe.com | 63,072,000 | 2 |
| wikipedia.org | 106,384,710 | 3.4 |
| x.com | 631,138,519 | 20 |

Framework defaults for comparison:

| Framework | Default |
|---|---|
| ASP.NET Core `app.UseHsts()` | 30 days |
| Spring Security | 1 year (31,536,000) |
| helmet.js (Node) | 180 days |
| nginx examples | 1 year |
| HSTS preload list minimum | **1 year** |
| Mozilla Observatory A+ threshold | 6 months |

**1 year wins for four reasons:** it's the modal value across production sites, it's the HSTS preload list minimum (so users opting into preload later don't need to retune), it's the Mozilla Observatory A+ rating threshold, and it's recoverable on a release-cycle timescale (worst-case rollback expires client memory within a year). ASP.NET Core's 30-day default is the outlier because `UseHsts()` doesn't know if the app is truly in production — `WithTls` is by definition a production-style deploy, so the same conservatism isn't warranted.

### `includeSubDomains` and `preload`: off by default

These are the irreversible-on-a-mistake knobs:

- `includeSubDomains` + a wrong apex hostname → every subdomain locked out of HTTP for `max-age`, no rollback for browsers that already cached the directive.
- `preload` → consent flag for submitting to Chrome/Firefox/Safari's baked-in lists. Not a default a scaffolding command should opt users into.

The user must type the words. The default is the safe-but-meaningful posture: real HSTS coverage for the apex hostname, no irreversible commitments.

### Why HSTS belongs at the gateway, not the app

1. **HSTS is a transport-layer assertion.** It says "this hostname does TLS." The thing that knows whether the hostname does TLS is the thing terminating TLS — which `WithTls` already made the gateway's job. Asking the backend to announce TLS posture is a layering violation; the backend, sitting behind a gateway, generally sees plain HTTP and has no first-hand knowledge of what the public hostname speaks.
2. **Polyglot is the killer argument for Aspire.** `app.UseHsts()` is ASP.NET Core only. Node has helmet. FastAPI has Starlette middleware. Static file servers have nothing ergonomic. Aspire AppHosts mix all of these freely; the gateway is the one place where the answer is uniform regardless of what's behind it.
3. **HSTS is per-hostname, and gateways are per-hostname.** `WithHostname("api.contoso.com")` is exactly the scope HSTS applies to. If a single gateway fans out to multiple backends and HSTS lives on each backend, the first backend that forgets the header creates a gap that prevents browsers from extending the cache.
4. **It composes correctly with the redirect.** The 301-on-port-80 and the `Strict-Transport-Security` header on port-443 are the two halves of the same policy. Both belonging to the gateway keeps them in one place.

## Mechanism on the wire

`.WithTls(letsencrypt)` produces (in addition to today's HTTPS listener + cert-manager wiring):

A redirect HTTPRoute bound to the HTTP listener only:

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: storefront-http-redirect
spec:
parentRefs:
- name: storefront
sectionName: http
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
```

A `ResponseHeaderModifier` filter applied to user HTTPRoutes attached to the HTTPS listener:

```yaml
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
set:
- name: Strict-Transport-Security
value: "max-age=31536000"
```

(When the user enables `includeSubDomains` and/or `preload`, the value becomes `max-age=31536000; includeSubDomains; preload`.)

## Why this is safe to make the default

### ACME HTTP-01 still works — solver wins by path precedence

When `WithHttp01Solver` is in play, cert-manager creates its own `HTTPRoute` on the same HTTP listener with an **Exact** path match for `/.well-known/acme-challenge/`. Per Gateway API route precedence rules (more specific path wins, Exact > PathPrefix), the solver's route handles ACME GETs while the redirect's `PathPrefix: /` is the fallback for everything else. No carve-out logic needed in our generator — the spec does it for us.

### Even when redirected, ACME tolerates it

RFC 8555 §8.3 and Let's Encrypt's HTTP-01 docs explicitly state the validator follows HTTP→HTTPS redirects on the challenge GET and does **not** validate the target's TLS certificate. The bootstrap `bootstrap.invalid` self-signed cert pattern doesn't break the challenge even if redirection were applied first.

### HSTS only applies after the cert is real

`Strict-Transport-Security` is only emitted by the HTTPS listener's HTTPRoutes — clients never see it while the gateway is serving the bootstrap self-signed cert (browsers don't apply HSTS over an invalid TLS connection). By the time a real user successfully connects, the real cert is in place and HSTS is sticky.

### Doesn't disturb the bootstrap chicken-and-egg

The redirect route is bound to the HTTP listener, which doesn't depend on any TLS secret. So the redirect is healthy from the first deploy.

### Portable across implementations

Gateway API route precedence, `RequestRedirect`, and `ResponseHeaderModifier` are all core features of the spec. AGC honors them, NGINX Gateway Fabric honors them, Envoy Gateway honors them.

## Out-of-the-box DX

**Before:**
```
$ curl -i http:///
HTTP/1.1 404 Not Found ← surprise

$ curl -i https:///
HTTP/1.1 200 OK
```

**After:**
```
$ curl -i http:///
HTTP/1.1 301 Moved Permanently
Location: https:///

$ curl -i https:///
HTTP/1.1 200 OK
strict-transport-security: max-age=31536000
```

The `curl http://...` step in the existing walkthrough still does something useful — it demonstrates the redirect, which is itself proof the HTTP listener is healthy enough for ACME to reach.

## Things to validate before shipping

1. Confirm AGC's actual route-precedence implementation behaves per the Gateway API spec when an `Exact` HTTPRoute and a `PathPrefix: /` HTTPRoute are both attached to the same listener at the same priority.
2. Confirm cert-manager's Gateway API HTTP-01 solver in `v1.20.x` (the version `AddCertManager` deploys) creates the solver HTTPRoute with `path.type: Exact`, not `PathPrefix`.
3. Confirm AGC emits the `Strict-Transport-Security` header set via `ResponseHeaderModifier` for responses generated by user backends, not only for AGC-synthesized responses.
4. Decide pipeline placement: emit the redirect HTTPRoute and apply the HSTS filter only when `WithTls` is called, not for non-TLS'd gateways. No behavior change for users who haven't asked for TLS.
5. Verify the redirect doesn't double-bounce on HTTPS — it can't, because it's bound to `sectionName: http`, but worth a test case.
6. Align `TlsOptions` / `HstsOptions` shape with whatever options-class conventions Aspire's hosting integrations already use (init-only properties vs setters, sealed vs not).

## Docs follow-up

When this ships, the "Plain HTTP returns 404 after adding `WithTls`" troubleshooting entry that was added to `src/frontend/src/content/docs/deployment/kubernetes/aks.mdx` in microsoft/aspire.dev#973 should be removed. The walkthrough's first-deploy and post-TLS curl steps can be updated to show the expected 301 + HSTS responses.

## Context

Surfaced during live AKS deployment validation of the `Aspire.Hosting.Azure.Kubernetes` 13.4 ingress/TLS walkthrough in microsoft/aspire.dev#973. Live deploy details, design conversation, and survey methodology are in the doc-tester session that produced this issue. The end-to-end test that exercises the same recipe is `tests/Aspire.Deployment.EndToEnd.Tests/AksAzureKubernetesEnvironmentCertManagerDeploymentTests.cs` (microsoft/aspire@3817b5b).

cc @mitchdenny

Contributor guide

Open the contributing guide

Research direction

Start by tracing WithTls and the existing HTTPS listener and cert-manager wiring in Aspire.Hosting.Azure.Kubernetes, then inspect the generated HTTPRoute behavior on the HTTP and HTTPS listeners. Use the Gateway API route-precedence rules and the curl examples as the behavioral checks. Done means TLS defaults to a 301 fallback on port 80 and HSTS on HTTPS, with the documented C# and TypeScript opt-outs and tuning options.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp, kubernetes, typescript
Domain
api, cloud, infrastructure
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.