HTTPS redirect middleware for the Infrastructure package (#52)
This commit is contained in:
@@ -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/"
|
||||
}
|
||||
Reference in New Issue
Block a user