From 1520c44b1663f5b1732e83e22ce169a6b04d4be9 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 22 Aug 2026 23:00:23 +0000 Subject: [PATCH] HTTPS redirect middleware for the Infrastructure package (#52) --- Packages/Infrastructure/README.md | 4 +- .../Extensions/HTTPFieldName+Constants.swift | 2 + .../Middlewares/HTTPSRedirectMiddleware.swift | 182 ++++++++++++++++++ .../HTTPSRedirectMiddlewareTests.swift | 179 +++++++++++++++++ .../Sources/App/Extensions/App+Build.swift | 13 +- .../Extensions/ConfigReader+Properties.swift | 14 ++ .../AbsoluteConfigKey+Constants.swift | 5 + .../Extensions/ConfigKey+Constants.swift | 6 + 8 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md index 36494f9..f6545b8 100644 --- a/Packages/Infrastructure/README.md +++ b/Packages/Infrastructure/README.md @@ -5,7 +5,7 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too | Role | Types | | --- | --- | | Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` | -| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | +| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` | | Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` | | Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives | | Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies | @@ -30,7 +30,7 @@ Sources/ │ ├── Enumerations/ AssetExtension │ ├── Extensions/ addController, plus the default header names and values │ ├── Methods/ FingerprintAssets -│ ├── Middlewares/ the five HTTP middlewares +│ ├── Middlewares/ the six HTTP middlewares │ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController │ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse │ └── Types/ Analytics, SocialCard, StructuredData (the latter two nesting their own types in SocialCard/ and StructuredData/) diff --git a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift index 9f89f0a..365523e 100644 --- a/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift +++ b/Packages/Infrastructure/Sources/Public/Extensions/HTTPFieldName+Constants.swift @@ -9,4 +9,6 @@ public extension HTTPField.Name { static let frameOptions = Self("X-Frame-Options")! /// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`). static let xForwardedFor = Self("X-Forwarded-For")! + /// The `X-Forwarded-Proto` field name (not provided as a standard `HTTPField.Name`). + static let xForwardedProto = Self("X-Forwarded-Proto")! } diff --git a/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift new file mode 100644 index 0000000..3d615f2 --- /dev/null +++ b/Packages/Infrastructure/Sources/Public/Middlewares/HTTPSRedirectMiddleware.swift @@ -0,0 +1,182 @@ +import Foundation +import HTTPTypes +import Hummingbird + +/// Answers a request forwarded over plain HTTP with `301 Moved Permanently` to the same path on the site's HTTPS origin. +/// +/// Behind a TLS-terminating proxy the server only sees plain HTTP, so the visitor's scheme survives only in the `X-Forwarded-Proto` header the proxy +/// sets. Redirecting collapses the `http://` and `https://` copies of every page onto one address — what a search engine consolidates a site's signals +/// against. +/// +/// Three deliberate choices: +/// - **`301`, not `302`.** A temporary redirect tells a crawler the HTTP address is the canonical one, so the HTTP URLs stay indexed. Browsers cache a +/// `301` for a long time, so the target must be settled before enabling this. +/// - **The target comes from ``Configuration/origin``, not the request's `Host` header.** A client cannot steer a configured origin, so it can neither +/// aim the redirect elsewhere nor poison a shared cache with the result. +/// - **`/.well-known/` is exempt.** A certificate authority looks there for an ACME challenge over plain HTTP; redirecting it away breaks renewal, and +/// the breakage surfaces only when the certificate expires. +/// +/// - Note: `Context` is the request context the middleware is resolved against. +public struct HTTPSRedirectMiddleware: Sendable { + + // MARK: Properties + + /// The origin the redirects point at, and whether the forwarded-protocol header is trusted. + private let configuration: Configuration + + /// Whether the middleware redirects at all, resolved once at initialization. + /// + /// An untrusted header disables it, and so does an origin that is not itself HTTPS: redirecting to a plain-HTTP origin would loop. + private let isEnabled: Bool + + // MARK: Initializers + + /// Creates an HTTPS-redirect middleware. + /// - Parameter configuration: the origin the redirects point at, and whether the forwarded-protocol header is trusted. + public init( + configuration: Configuration + ) { + self.configuration = configuration + self.isEnabled = configuration.trustForwardedProto + && configuration.origin.hasPrefix(.httpsPrefix) + } + +} + +// MARK: - RouterMiddleware + +extension HTTPSRedirectMiddleware: RouterMiddleware { + + // MARK: Functions + + /// Redirects a request forwarded over plain HTTP, and passes every other request down the chain. + /// - Parameters: + /// - request: the incoming request. + /// - context: the context the request is resolved against. + /// - next: the next responder in the middleware chain. + /// - Returns: the redirect, or the downstream response. + /// - Throws: any error thrown downstream. + public func handle( + _ request: Request, + context: Context, + next: (Request, Context) async throws -> Response + ) async throws -> Response { + guard + isEnabled, + isForwardedOverHTTP(request), + !isWellKnown(request) + else { + return try await next( + request, + context + ) + } + + var response = Response(status: .movedPermanently) + + response.headers[.location] = configuration.origin + target(of: request) + + return response + } + +} + +// MARK: - Helpers + +private extension HTTPSRedirectMiddleware { + + // MARK: Methods + + /// Whether the proxy reported the visitor's request as plain HTTP. + /// + /// Only the leftmost entry is read: a chain of proxies appends to the header, so that entry is the scheme the visitor used. The comparison is + /// case-insensitive, the header carrying a scheme name rather than a fixed-case token. + /// - Parameter request: the incoming request. + /// - Returns: `true` when the header's first entry names plain HTTP. + func isForwardedOverHTTP( + _ request: Request + ) -> Bool { + request.headers[.xForwardedProto]? + .split(separator: ",") + .first? + .trimmingCharacters(in: .whitespaces) + .lowercased() == .httpScheme + } + + /// Whether the request addresses the reserved well-known space, which is left on plain HTTP so an ACME challenge stays reachable. + /// - Parameter request: the incoming request. + /// - Returns: `true` when the path falls inside the well-known space. + func isWellKnown( + _ request: Request + ) -> Bool { + request.uri.path.hasPrefix(.wellKnownPrefix) + } + + /// The path and query the redirect preserves, so a visitor lands on the address they asked for. + /// + /// Rebuilt from the parsed components rather than the raw request target: a target in absolute form would append a second origin to the first. + /// - Parameter request: the incoming request. + /// - Returns: the path, followed by the query when the request carries one. + func target( + of request: Request + ) -> String { + guard + let query = request.uri.query, + !query.isEmpty + else { + return request.uri.path + } + + return request.uri.path + .querySeparator + query + } + +} + +// MARK: - Configuration + +extension HTTPSRedirectMiddleware { + /// The origin an ``HTTPSRedirectMiddleware`` redirects to, and whether it trusts the header telling it to. + public struct Configuration: Sendable { + + // MARK: Properties + + /// The public origin the redirects point at (scheme and host, no trailing slash). + /// + /// An origin that is not itself HTTPS disables the middleware rather than redirecting into a loop. + public let origin: String + + /// Whether the visitor's scheme is read from the `X-Forwarded-Proto` header. + /// + /// This is the middleware's only trigger. Enable it solely behind a reverse proxy that sets the header: on a directly reachable server the header + /// is client-supplied, so it stays off by default. + public let trustForwardedProto: Bool + + // MARK: Initializers + + /// Creates an HTTPS-redirect configuration. + /// - Parameters: + /// - origin: the public origin the redirects point at (scheme and host, no trailing slash). + /// - trustForwardedProto: whether the visitor's scheme is read from the `X-Forwarded-Proto` header. Defaults to `false`. + public init( + origin: String, + trustForwardedProto: Bool = false + ) { + self.origin = origin + self.trustForwardedProto = trustForwardedProto + } + + } +} + +// MARK: - String+Constants + +private extension String { + /// The forwarded-protocol value naming a request made over plain HTTP. + static let httpScheme = "http" + /// The scheme prefix a redirect target must carry for the redirect to terminate. + static let httpsPrefix = "https://" + /// The delimiter placed between a redirect target's path and its query. + static let querySeparator = "?" + /// The reserved path prefix left on plain HTTP, so an ACME challenge stays reachable. + static let wellKnownPrefix = "/.well-known/" +} diff --git a/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift new file mode 100644 index 0000000..5296a98 --- /dev/null +++ b/Packages/Infrastructure/Tests/Cases/Public/Middlewares/HTTPSRedirectMiddlewareTests.swift @@ -0,0 +1,179 @@ +import HTTPTypes +import Hummingbird +import HummingbirdTesting +import Testing + +@testable import Infrastructure + +@Suite( + "HTTPSRedirectMiddleware middleware", + .tags(.middleware) +) +struct HTTPSRedirectMiddlewareTests { + + // MARK: Functional tests + + @Test + func `redirects a request forwarded over plain http`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .movedPermanently) + #expect(response.headers[.location] == "https://example.com/hello") + } + } + } + + @Test + func `preserves the path and the query of the redirected request`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello?utm_source=test&utm_medium=email", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.headers[.location] == "https://example.com/hello?utm_source=test&utm_medium=email") + } + } + } + + @Test + func `matches the forwarded scheme regardless of its casing`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "HTTP"] + ) { response in + #expect(response.status == .movedPermanently) + } + } + } + + @Test + func `reads the leftmost entry of a proxy chain`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http, https"] + ) { response in + #expect(response.status == .movedPermanently) + } + } + } + + @Test + func `passes a request forwarded over https through`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "https"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + + @Test + func `passes a request without the forwarded header through`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `passes every request through when the header is not trusted`() async throws { + try await app( + configuration: .init( + origin: "https://example.com", + trustForwardedProto: false + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + } + } + } + + @Test + func `leaves the well-known space on plain http`() async throws { + try await app().test(.router) { client in + try await client.execute( + uri: "/.well-known/acme-challenge/token", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + #expect(response.headers[.location] == nil) + } + } + } + + @Test + func `passes every request through when the origin is not itself https`() async throws { + try await app( + configuration: .init( + origin: "http://example.com", + trustForwardedProto: true + ) + ).test(.router) { client in + try await client.execute( + uri: "/hello", + method: .get, + headers: [.xForwardedProto: "http"] + ) { response in + #expect(response.status == .ok) + } + } + } + +} + +// MARK: - Helpers + +private extension HTTPSRedirectMiddlewareTests { + + // MARK: Methods + + /// Builds an application whose router applies the HTTPS-redirect middleware ahead of a `/hello` + /// route returning a plain body and a `/.well-known/acme-challenge/token` route standing in for + /// a certificate authority's challenge file. + func app( + configuration: HTTPSRedirectMiddleware.Configuration = .init( + origin: "https://example.com", + trustForwardedProto: true + ) + ) -> some ApplicationProtocol { + let router = Router() + + router.addMiddleware { + HTTPSRedirectMiddleware(configuration: configuration) + } + + router.get("hello") { _, _ in + "Hello!" + } + + router.get(".well-known/acme-challenge/token") { _, _ in + "token" + } + + return Application(router: router) + } + +} diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index aa93d86..a46ce9f 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -52,6 +52,7 @@ func application( analytics: reader.analytics, cacheControl: reader.cacheControl, compressionMinResponseSize: reader.compressionMinResponseSize, + httpsRedirect: reader.httpsRedirect, rateLimit: reader.rateLimit, securityHeaders: reader.securityHeaders, logLevel: reader.logLevel, @@ -135,21 +136,23 @@ private func logger( /// Builds the application's router. /// /// Registers the request-logging middleware, the security-headers middleware that stamps the given `securityHeaders` onto every response, the -/// vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses +/// HTTPS-redirect middleware that bounces requests forwarded over plain HTTP to the canonical origin, the vary middleware that marks every response as varying on `Accept-Encoding`, the response-compression middleware that compresses responses /// larger than `minimumResponseSizeToCompress` when the client advertises support, the localization middleware that negotiates the request's /// language from its `Accept-Language` header, the not-found middleware that serves the error page, and the static file middleware that serves the /// contents of `staticFilesPath` (tagging responses with the given `cacheControl` directives), then adds the `RootController` routes that /// render the landing page, and the `HealthController` routes that serve the health check. /// /// The security-headers middleware sits just inside request logging so it covers every response that reaches a client — the landing page, the compressed -/// responses, the rendered error page, and the served static files. +/// responses, the rendered not-found page, and the served static files. The HTTPS redirect sits directly beneath it, so a redirect carries the security +/// headers but skips the negotiation, compression, and file lookup it would otherwise pay for. /// - Parameters: /// - staticFilesPath: the folder, relative to the working directory, the static files are served from. /// - assetVersion: the version token the pages append to their asset URLs, or `nil` to leave them unversioned. /// - analytics: the analytics tracker both pages embed, or `nil` to omit it. /// - cacheControl: the cache-control directives applied to the served static files. /// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied. -/// - rateLimit: the rate limit applied to the rate-limited routes. +/// - httpsRedirect: the origin plain-HTTP requests are redirected to, and whether the forwarded-protocol header is trusted. +/// - rateLimit: the rate limit applied to the subscription endpoint. /// - securityHeaders: the security headers applied to every response. /// - logLevel: the level the request-logging middleware logs at. /// - probe: the probe consulted by the `HealthController` readiness route. @@ -160,6 +163,7 @@ private func router( analytics: Analytics?, cacheControl: CacheControl, compressionMinResponseSize: Int, + httpsRedirect: HTTPSRedirectMiddleware.Configuration, rateLimit: RateLimitMiddleware.Configuration, securityHeaders: SecurityHeadersMiddleware.Configuration, logLevel: Logger.Level, @@ -177,6 +181,9 @@ private func router( SecurityHeadersMiddleware( configuration: securityHeaders ) + HTTPSRedirectMiddleware( + configuration: httpsRedirect + ) VaryMiddleware() ResponseCompressionMiddleware( minimumResponseSizeToCompress: compressionMinResponseSize diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index b317f45..7f115f4 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -145,6 +145,20 @@ package extension ConfigReader { } } + /// The HTTPS redirect middleware configuration, built from the `https.trustForwardedProto` key and ``siteOrigin``. + /// + /// Off by default, so a deployment without a proxy in front never redirects on a header its clients could have written themselves. The redirects + /// point at ``siteOrigin`` — the same value the pages build their canonical URLs from, so the two cannot disagree. + var httpsRedirect: HTTPSRedirectMiddleware.Configuration { + .init( + origin: siteOrigin, + trustForwardedProto: bool( + forKey: .HTTPS.trustForwardedProto, + default: false + ) + ) + } + /// The minimum log level the application emits at, read from the `log.level` key. /// /// Falls back to `.info` when the key is unset or its value names no `Logger.Level` case. diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 62dd206..216af97 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -58,6 +58,11 @@ extension AbsoluteConfigKey { /// The absolute configuration key for the server's name. public static let serverName: AbsoluteConfigKey = .init(.HTTP.serverName) } + /// A namespace for the HTTPS redirect configuration keys, as absolute keys. + public enum HTTPS { + /// The absolute configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header. + public static let trustForwardedProto: AbsoluteConfigKey = .init(.HTTPS.trustForwardedProto) + } /// A namespace for the logging configuration keys, as absolute keys. public enum Log { /// The absolute configuration key for the minimum log level. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 9fcddaa..5199d99 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -58,6 +58,12 @@ extension ConfigKey { /// The configuration key for the server's name. public static let serverName: ConfigKey = "http.serverName" } + /// A namespace for the HTTPS redirect configuration keys. + public enum HTTPS { + /// The configuration key for reading the visitor's original scheme from the `X-Forwarded-Proto` header, redirecting the plain-HTTP + /// ones to the site origin (enable only behind a trusted proxy that sets the header). + public static let trustForwardedProto: ConfigKey = "https.trustForwardedProto" + } /// A namespace for the logging configuration keys. public enum Log { /// The configuration key for the minimum log level.