Security header setup for the Website service (#8)
This PR contains the work done to add a `SecurityHeadersMiddleware` middleware that stamps hardened security-related HTTP headers onto every response. To provide further details about the work: * Implemented the `SecurityHeadersMiddleware` middleware, which precomputes headers once from a `Configuration` object and applies them to every response: * _Content-Security-Policy_, * _X-Content-Type-Options_, * _X-Frame-Options_, * _Referrer-Policy_, * _Permissions-Policy_, * _Strict-Transport-Security_ (optional). * Integrated this middleware into the router (near the top of the chain), reading each value from configuration with hardened defaults. * The _Strict-Transport-Security_ has no default value — omitted unless explicitly set, so it stays off in plain-HTTP during development and on only behind TLS. * Added security-header constants keys and values. Reviewed-on: rock-n-code/loud-amsterdam#8 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com> Co-committed-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -6,8 +6,8 @@ import WebsiteCore
|
||||
|
||||
/// Builds the website application.
|
||||
///
|
||||
/// Reads the log level, server name, static files location, and minimum response size to
|
||||
/// compress from the configuration, then assembles the router, server configuration, and logger.
|
||||
/// Reads the log level, server name, static files location, minimum response size to compress, and
|
||||
/// security headers from the configuration, then assembles the router, server configuration, and logger.
|
||||
/// - Parameter reader: the configuration reader the values are read from.
|
||||
/// - Returns: the configured application, ready to run as a service.
|
||||
func application(
|
||||
@@ -44,12 +44,16 @@ func application(
|
||||
forKey: .Path.staticFiles,
|
||||
default: .Path.staticResources
|
||||
)
|
||||
let securityHeaders = securityHeaders(
|
||||
reader: reader
|
||||
)
|
||||
|
||||
return Application(
|
||||
router: router(
|
||||
staticFilesPath: staticFilesPath,
|
||||
cacheControl: cacheControl,
|
||||
compressionMinResponseSize: compressionMinResponseSize,
|
||||
securityHeaders: securityHeaders,
|
||||
logLevel: logLevel
|
||||
),
|
||||
configuration: ApplicationConfiguration(
|
||||
@@ -90,6 +94,44 @@ private func cacheControl(
|
||||
])
|
||||
}
|
||||
|
||||
/// Builds the security-headers configuration applied to every response.
|
||||
///
|
||||
/// Each header value falls back to the hardened default in `String.Security` when the matching
|
||||
/// configuration key is unset. `Strict-Transport-Security` has no default: it is read as an optional
|
||||
/// and omitted entirely unless explicitly configured, so it stays off in plain-HTTP development and
|
||||
/// is enabled only behind TLS in production.
|
||||
/// - Parameter reader: the configuration reader the header values are read from.
|
||||
/// - Returns: the configured security-headers configuration.
|
||||
private func securityHeaders(
|
||||
reader: ConfigReader
|
||||
) -> SecurityHeadersMiddleware<AppRequestContext>.Configuration {
|
||||
.init(
|
||||
contentSecurityPolicy: reader.string(
|
||||
forKey: .Security.contentSecurityPolicy,
|
||||
default: .Security.contentSecurityPolicy
|
||||
),
|
||||
contentTypeOptions: reader.string(
|
||||
forKey: .Security.contentTypeOptions,
|
||||
default: .Security.contentTypeOptions
|
||||
),
|
||||
frameOptions: reader.string(
|
||||
forKey: .Security.frameOptions,
|
||||
default: .Security.frameOptions
|
||||
),
|
||||
referrerPolicy: reader.string(
|
||||
forKey: .Security.referrerPolicy,
|
||||
default: .Security.referrerPolicy
|
||||
),
|
||||
permissionsPolicy: reader.string(
|
||||
forKey: .Security.permissionsPolicy,
|
||||
default: .Security.permissionsPolicy
|
||||
),
|
||||
strictTransportSecurity: reader.string(
|
||||
forKey: .Security.strictTransportSecurity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the application's logger.
|
||||
/// - Parameters:
|
||||
/// - serverName: the label applied to the logger.
|
||||
@@ -108,27 +150,37 @@ private func logger(
|
||||
|
||||
/// Builds the application's router.
|
||||
///
|
||||
/// Registers the request-logging middleware, the response-compression middleware that compresses
|
||||
/// Registers the request-logging middleware, the security-headers middleware that stamps the given
|
||||
/// `securityHeaders` onto every response, the response-compression middleware that compresses
|
||||
/// responses larger than `minimumResponseSizeToCompress` when the client advertises support, 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.
|
||||
///
|
||||
/// 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.
|
||||
/// - Parameters:
|
||||
/// - staticFilesPath: the folder, relative to the working directory, the static files are served from.
|
||||
/// - cacheControl: the cache-control directives applied to the served static files.
|
||||
/// - compressionMinResponseSize: the minimum response body size, in bytes, before compression is applied.
|
||||
/// - securityHeaders: the security headers applied to every response.
|
||||
/// - logLevel: the level the request-logging middleware logs at.
|
||||
/// - Returns: the configured router.
|
||||
private func router(
|
||||
staticFilesPath: String,
|
||||
cacheControl: CacheControl,
|
||||
compressionMinResponseSize: Int,
|
||||
securityHeaders: SecurityHeadersMiddleware<AppRequestContext>.Configuration,
|
||||
logLevel: Logger.Level
|
||||
) -> Router<AppRequestContext> {
|
||||
let router = Router(context: AppRequestContext.self)
|
||||
|
||||
router.addMiddleware {
|
||||
LogRequestsMiddleware(logLevel)
|
||||
SecurityHeadersMiddleware(
|
||||
configuration: securityHeaders
|
||||
)
|
||||
ResponseCompressionMiddleware(
|
||||
minimumResponseSizeToCompress: compressionMinResponseSize
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import HTTPTypes
|
||||
|
||||
extension HTTPField.Name {
|
||||
/// The `Permissions-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let permissionsPolicy = Self("Permissions-Policy")!
|
||||
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let referrerPolicy = Self("Referrer-Policy")!
|
||||
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let frameOptions = Self("X-Frame-Options")!
|
||||
}
|
||||
@@ -34,4 +34,19 @@ extension AbsoluteConfigKey {
|
||||
/// The absolute configuration key for the directory the static files are served from.
|
||||
public static let staticFiles: AbsoluteConfigKey = .init(.Path.staticFiles)
|
||||
}
|
||||
/// A namespace for the security headers configuration keys, as absolute keys.
|
||||
public enum Security {
|
||||
/// The absolute configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: AbsoluteConfigKey = .init(.Security.contentSecurityPolicy)
|
||||
/// The absolute configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: AbsoluteConfigKey = .init(.Security.contentTypeOptions)
|
||||
/// The absolute configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: AbsoluteConfigKey = .init(.Security.frameOptions)
|
||||
/// The absolute configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: AbsoluteConfigKey = .init(.Security.referrerPolicy)
|
||||
/// The absolute configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: AbsoluteConfigKey = .init(.Security.permissionsPolicy)
|
||||
/// The absolute configuration key for the `Strict-Transport-Security` header value.
|
||||
public static let strictTransportSecurity: AbsoluteConfigKey = .init(.Security.strictTransportSecurity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,19 @@ extension ConfigKey {
|
||||
/// The configuration key for the directory the static files are served from.
|
||||
public static let staticFiles: ConfigKey = "path.staticFiles"
|
||||
}
|
||||
/// A namespace for the security headers configuration keys.
|
||||
public enum Security {
|
||||
/// The configuration key for the `Content-Security-Policy` header value.
|
||||
public static let contentSecurityPolicy: ConfigKey = "security.contentSecurityPolicy"
|
||||
/// The configuration key for the `X-Content-Type-Options` header value.
|
||||
public static let contentTypeOptions: ConfigKey = "security.contentTypeOptions"
|
||||
/// The configuration key for the `X-Frame-Options` header value.
|
||||
public static let frameOptions: ConfigKey = "security.frameOptions"
|
||||
/// The configuration key for the `Referrer-Policy` header value.
|
||||
public static let referrerPolicy: ConfigKey = "security.referrerPolicy"
|
||||
/// The configuration key for the `Permissions-Policy` header value.
|
||||
public static let permissionsPolicy: ConfigKey = "security.permissionsPolicy"
|
||||
/// The configuration key for the `Strict-Transport-Security` header value (omitted when unset).
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,26 @@ extension String {
|
||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||
public static let staticResources = "Resources/Static"
|
||||
}
|
||||
/// A namespace for the security headers' default configuration values.
|
||||
///
|
||||
/// `Strict-Transport-Security` is intentionally absent: it is only safe over HTTPS and is
|
||||
/// "sticky" in browsers, so it stays off unless explicitly configured in production.
|
||||
public enum Security {
|
||||
/// The default `Content-Security-Policy`.
|
||||
///
|
||||
/// Restricts every resource to the site's own origin. `style-src` additionally allows
|
||||
/// `'unsafe-inline'` because ``ErrorPage`` ships an inline `<style>` block; remove it once
|
||||
/// the error page's styles move to an external stylesheet.
|
||||
public static let contentSecurityPolicy = "default-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
||||
/// The default `X-Content-Type-Options` (disables MIME sniffing).
|
||||
public static let contentTypeOptions = "nosniff"
|
||||
/// The default `X-Frame-Options` (forbids framing the page).
|
||||
public static let frameOptions = "DENY"
|
||||
/// The default `Referrer-Policy`.
|
||||
public static let referrerPolicy = "strict-origin-when-cross-origin"
|
||||
/// The default `Permissions-Policy` (denies access to powerful browser features the site does not use).
|
||||
public static let permissionsPolicy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
}
|
||||
/// A namespace for the server string constants.
|
||||
public enum Server {
|
||||
/// The website server's name.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import HTTPTypes
|
||||
import Hummingbird
|
||||
|
||||
/// Stamps a set of security-related HTTP headers onto every response.
|
||||
///
|
||||
/// Placed at (or near) the top of the middleware chain, it adds the configured headers to whatever
|
||||
/// response bubbles back up — the rendered landing page, the ``ErrorPage`` produced by
|
||||
/// ``NotFoundMiddleware``, and every static file served by `FileMiddleware` — so the browser applies
|
||||
/// the strict, hardened interpretation of the content instead of its lenient legacy defaults.
|
||||
///
|
||||
/// The headers are precomputed once from the ``Configuration`` at initialization and reused for
|
||||
/// every request, so the per-request cost is a handful of header copies.
|
||||
public struct SecurityHeadersMiddleware<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The precomputed headers applied to every response.
|
||||
private let fields: HTTPFields
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a security-headers middleware.
|
||||
/// - Parameter configuration: the headers applied to every response. Defaults to a hardened
|
||||
/// baseline suitable for a static site, with `Strict-Transport-Security` left off (see
|
||||
/// ``Configuration``).
|
||||
public init(
|
||||
configuration: Configuration = .init()
|
||||
) {
|
||||
self.fields = configuration.fields
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterMiddleware
|
||||
|
||||
extension SecurityHeadersMiddleware: RouterMiddleware {
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Passes the request down the chain and stamps the configured security headers onto the
|
||||
/// response on the way back up.
|
||||
///
|
||||
/// Existing values for the same header names are replaced so downstream middleware cannot leave
|
||||
/// a weaker policy in place.
|
||||
/// - Parameters:
|
||||
/// - request: the incoming request.
|
||||
/// - context: the context the request is resolved against.
|
||||
/// - next: the next responder in the middleware chain.
|
||||
/// - Returns: the downstream response with the security headers applied.
|
||||
/// - Throws: any error thrown downstream.
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response = try await next(request, context)
|
||||
|
||||
for field in fields {
|
||||
response.headers[field.name] = field.value
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SecurityHeadersMiddleware.Configuration {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The configuration expressed as the headers to apply, omitting any whose value is `nil`.
|
||||
var fields: HTTPFields {
|
||||
var fields = HTTPFields()
|
||||
|
||||
fields[.contentSecurityPolicy] = contentSecurityPolicy
|
||||
fields[.xContentTypeOptions] = contentTypeOptions
|
||||
fields[.frameOptions] = frameOptions
|
||||
fields[.referrerPolicy] = referrerPolicy
|
||||
fields[.permissionsPolicy] = permissionsPolicy
|
||||
fields[.strictTransportSecurity] = strictTransportSecurity
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
extension SecurityHeadersMiddleware {
|
||||
/// The set of security headers a ``SecurityHeadersMiddleware`` applies.
|
||||
///
|
||||
/// Each property maps to a single response header. A `nil` value omits that header entirely,
|
||||
/// which is how `Strict-Transport-Security` stays disabled by default: it is only safe to send
|
||||
/// over HTTPS and is "sticky" in browsers, so it must stay off in plain-HTTP development and be
|
||||
/// switched on (via configuration) only in TLS-terminated production.
|
||||
public struct Configuration: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Content-Security-Policy` value (controls which sources the browser will load).
|
||||
public let contentSecurityPolicy: String?
|
||||
/// The `X-Content-Type-Options` value (disables MIME sniffing when set to `nosniff`).
|
||||
public let contentTypeOptions: String?
|
||||
/// The `X-Frame-Options` value (controls whether the page may be framed).
|
||||
public let frameOptions: String?
|
||||
/// The `Referrer-Policy` value (controls how much referrer information is shared).
|
||||
public let referrerPolicy: String?
|
||||
/// The `Permissions-Policy` value (gates access to powerful browser features).
|
||||
public let permissionsPolicy: String?
|
||||
/// The `Strict-Transport-Security` value, or `nil` to omit the header (the default).
|
||||
public let strictTransportSecurity: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a security-headers configuration.
|
||||
///
|
||||
/// Every parameter defaults to the hardened baseline defined in `String.Security`, except
|
||||
/// `strictTransportSecurity`, which defaults to `nil` (omitted). Pass `nil` for any header
|
||||
/// to drop it from the response.
|
||||
/// - Parameters:
|
||||
/// - contentSecurityPolicy: the `Content-Security-Policy` value.
|
||||
/// - contentTypeOptions: the `X-Content-Type-Options` value.
|
||||
/// - frameOptions: the `X-Frame-Options` value.
|
||||
/// - referrerPolicy: the `Referrer-Policy` value.
|
||||
/// - permissionsPolicy: the `Permissions-Policy` value.
|
||||
/// - strictTransportSecurity: the `Strict-Transport-Security` value, or `nil` to omit it.
|
||||
public init(
|
||||
contentSecurityPolicy: String? = String.Security.contentSecurityPolicy,
|
||||
contentTypeOptions: String? = String.Security.contentTypeOptions,
|
||||
frameOptions: String? = String.Security.frameOptions,
|
||||
referrerPolicy: String? = String.Security.referrerPolicy,
|
||||
permissionsPolicy: String? = String.Security.permissionsPolicy,
|
||||
strictTransportSecurity: String? = nil
|
||||
) {
|
||||
self.contentSecurityPolicy = contentSecurityPolicy
|
||||
self.contentTypeOptions = contentTypeOptions
|
||||
self.frameOptions = frameOptions
|
||||
self.referrerPolicy = referrerPolicy
|
||||
self.permissionsPolicy = permissionsPolicy
|
||||
self.strictTransportSecurity = strictTransportSecurity
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ struct AppTests {
|
||||
|
||||
@Test
|
||||
func `landing page to be served at root`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
@@ -51,7 +53,9 @@ struct AppTests {
|
||||
func `static files to be served`(
|
||||
staticFile file: StaticFile
|
||||
) async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/\(file.relativePath)",
|
||||
method: .get
|
||||
@@ -73,7 +77,9 @@ struct AppTests {
|
||||
|
||||
@Test
|
||||
func `response to be compressed when the client supports it`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get,
|
||||
@@ -87,7 +93,9 @@ struct AppTests {
|
||||
|
||||
@Test
|
||||
func `response to not be compressed when the client does not support it`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
@@ -100,7 +108,9 @@ struct AppTests {
|
||||
|
||||
@Test
|
||||
func `error page to be served when not found`() async throws {
|
||||
try await app.test(.router) { client in
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
@@ -114,37 +124,100 @@ struct AppTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the landing page`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `security headers to be applied to the error page`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/this-path-does-not-exist",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .notFound)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `strict-transport-security to be applied when configured`() async throws {
|
||||
try await app(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: "max-age=31536000; includeSubDomains"
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.strictTransportSecurity] == "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AppTests {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var app: some ApplicationProtocol {
|
||||
get async {
|
||||
await application(
|
||||
reader: reader(
|
||||
staticFilesPath: staticFilesPath
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func reader(
|
||||
staticFilesPath: String
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
])
|
||||
])
|
||||
func app(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) async -> some ApplicationProtocol {
|
||||
await application(
|
||||
reader: reader(
|
||||
staticFilesPath: staticFilesPath,
|
||||
strictTransportSecurity: strictTransportSecurity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func reader(
|
||||
staticFilesPath: String,
|
||||
strictTransportSecurity: String? = nil
|
||||
) -> ConfigReader {
|
||||
ConfigReader(providers: [{
|
||||
if let strictTransportSecurity {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
.Security.strictTransportSecurity: .init(stringLiteral: strictTransportSecurity)
|
||||
])
|
||||
} else {
|
||||
InMemoryProvider(values: [
|
||||
.HTTP.host: "127.0.0.1",
|
||||
.HTTP.port: "0",
|
||||
.Log.level: "trace",
|
||||
.Path.staticFiles: .init(stringLiteral: staticFilesPath),
|
||||
])
|
||||
}
|
||||
}()])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteCore
|
||||
|
||||
@Suite("SecurityHeadersMiddleware middleware")
|
||||
struct SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `applies the default security headers to a response`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.contentSecurityPolicy] == String.Security.contentSecurityPolicy)
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
#expect(response.headers[.frameOptions] == String.Security.frameOptions)
|
||||
#expect(response.headers[.referrerPolicy] == String.Security.referrerPolicy)
|
||||
#expect(response.headers[.permissionsPolicy] == String.Security.permissionsPolicy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits strict-transport-security by default`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies strict-transport-security when configured`() async throws {
|
||||
let value = "max-age=31536000; includeSubDomains"
|
||||
|
||||
try await app(
|
||||
configuration: .init(strictTransportSecurity: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.strictTransportSecurity] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies a custom header value`() async throws {
|
||||
let value = "default-src 'none'"
|
||||
|
||||
try await app(
|
||||
configuration: .init(contentSecurityPolicy: value)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.contentSecurityPolicy] == value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `omits a header whose configured value is nil`() async throws {
|
||||
try await app(
|
||||
configuration: .init(contentTypeOptions: nil)
|
||||
).test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/hello",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `replaces an existing header value set downstream`() async throws {
|
||||
try await app().test(.router) { client in
|
||||
try await client.execute(
|
||||
uri: "/weak",
|
||||
method: .get
|
||||
) { response in
|
||||
#expect(response.headers[.xContentTypeOptions] == String.Security.contentTypeOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension SecurityHeadersMiddlewareTests {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Builds an application whose router applies the security-headers middleware ahead of two
|
||||
/// routes: `/hello` returns a plain body, and `/weak` returns a response that already carries a
|
||||
/// deliberately weak `X-Content-Type-Options` value for the middleware to override.
|
||||
func app(
|
||||
configuration: SecurityHeadersMiddleware<BasicRequestContext>.Configuration = .init()
|
||||
) -> some ApplicationProtocol {
|
||||
let router = Router()
|
||||
|
||||
router.addMiddleware {
|
||||
SecurityHeadersMiddleware(configuration: configuration)
|
||||
}
|
||||
|
||||
router.get("hello") { _, _ in
|
||||
"Hello!"
|
||||
}
|
||||
|
||||
router.get("weak") { _, _ -> Response in
|
||||
var response = Response(status: .ok)
|
||||
|
||||
response.headers[.xContentTypeOptions] = "weak"
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return Application(router: router)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,3 +20,4 @@ services:
|
||||
environment:
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTP_SERVER_NAME: ${HTTP_SERVER_NAME:-LoudWebsite}
|
||||
SECURITY_STRICT_TRANSPORT_SECURITY: "${SECURITY_STRICT_TRANSPORT_SECURITY:-max-age=31536000; includeSubDomains}"
|
||||
|
||||
Reference in New Issue
Block a user