HTTPS redirect middleware for the Infrastructure package (#52)

This commit is contained in:
2026-08-24 17:32:02 +02:00
parent fcfb3f21d5
commit 1520c44b16
8 changed files with 400 additions and 5 deletions
+2 -2
View File
@@ -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/)
@@ -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")!
}
@@ -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<Context: RequestContext>: 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/"
}
@@ -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<BasicRequestContext>.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)
}
}