#3: SecurityHeadersInterceptor — strict baseline + CspOverride ctor (Option B)
Aligns the default CSP, X-Frame-Options, HSTS and Permissions-Policy with
docs/security-baseline.md:
- script-src/style-src drop 'unsafe-inline' and the unpkg.com allowance
- img-src narrows from 'self' data: https: → 'self' data:
- connect-src narrows from 'self' wss: ws: → 'self'
- frame-ancestors flips from 'self' → 'none'
- X-Frame-Options flips from SAMEORIGIN → DENY
- HSTS keeps max-age=63072000 but drops includeSubDomains by default
(apex-clobbering hazard noted in audit #1)
- Permissions-Policy header added with the baseline sensor allowlist
Adds a CspOverride struct + ctor so consumers that genuinely need a
relaxation (Swagger UI subtree, cross-origin connect, …) can flip
individual directives without forking the interceptor. Empty fields
inherit the strict baseline.
Bumps to 0.3.6 (alongside owner's pending #4 + #5 + #6 work).
Closes #3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bccd57f47e
commit
0d2312499e
4 changed files with 146 additions and 19 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.14)
|
cmake_minimum_required(VERSION 3.14)
|
||||||
project(oatpp-authkit VERSION 0.3.5 LANGUAGES CXX)
|
project(oatpp-authkit VERSION 0.3.6 LANGUAGES CXX)
|
||||||
|
|
||||||
# Header-only interface library — no compilation, just an include path and
|
# Header-only interface library — no compilation, just an include path and
|
||||||
# a CMake config package so consumers do:
|
# a CMake config package so consumers do:
|
||||||
|
|
|
||||||
|
|
@ -3,37 +3,119 @@
|
||||||
|
|
||||||
#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp"
|
#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace oatpp_authkit {
|
namespace oatpp_authkit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Per-directive overrides for the strict baseline CSP.
|
||||||
|
*
|
||||||
|
* Empty string = "use the strict baseline value for this directive".
|
||||||
|
* Set a directive to a non-empty string to relax (or further tighten) it.
|
||||||
|
*
|
||||||
|
* Example — allow Swagger UI's inline scripts on a single subtree only by
|
||||||
|
* wrapping this interceptor and swapping `scriptSrc` for matching paths:
|
||||||
|
*
|
||||||
|
* CspOverride relaxed;
|
||||||
|
* relaxed.scriptSrc = "'self' 'unsafe-inline'";
|
||||||
|
* relaxed.styleSrc = "'self' 'unsafe-inline'";
|
||||||
|
*
|
||||||
|
* The vast majority of consumers should leave this default-constructed.
|
||||||
|
*/
|
||||||
|
struct CspOverride {
|
||||||
|
std::string defaultSrc; // baseline: 'self'
|
||||||
|
std::string scriptSrc; // baseline: 'self'
|
||||||
|
std::string styleSrc; // baseline: 'self'
|
||||||
|
std::string imgSrc; // baseline: 'self' data:
|
||||||
|
std::string connectSrc; // baseline: 'self'
|
||||||
|
std::string fontSrc; // baseline: 'self'
|
||||||
|
std::string frameAncestors; // baseline: 'none'
|
||||||
|
std::string baseUri; // baseline: 'self'
|
||||||
|
std::string formAction; // baseline: 'self'
|
||||||
|
|
||||||
|
/** Set to false to drop the HSTS header entirely (e.g. for non-TLS dev). */
|
||||||
|
bool sendHsts = true;
|
||||||
|
/** Set to true to add `includeSubDomains` to HSTS (off by default — apex-clobbering hazard). */
|
||||||
|
bool hstsIncludeSubdomains = false;
|
||||||
|
|
||||||
|
/** Override X-Frame-Options. Empty = baseline `DENY`. */
|
||||||
|
std::string xFrameOptions;
|
||||||
|
/** Override Permissions-Policy. Empty = baseline (sensors disabled). */
|
||||||
|
std::string permissionsPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Response interceptor that adds standard security headers to all responses.
|
* @brief Response interceptor that adds standard security headers to all responses.
|
||||||
*
|
*
|
||||||
* Headers added:
|
* Defaults track `docs/security-baseline.md`:
|
||||||
* - X-Content-Type-Options: nosniff — prevents MIME type sniffing
|
* - `X-Content-Type-Options: nosniff`
|
||||||
* - X-Frame-Options: SAMEORIGIN — prevents clickjacking
|
* - `X-Frame-Options: DENY`
|
||||||
* - Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
* - `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
* - Content-Security-Policy — restricts resource loading sources
|
* - `Strict-Transport-Security: max-age=63072000` (no `includeSubDomains` by default)
|
||||||
|
* - `Permissions-Policy: accelerometer=(), camera=(), …`
|
||||||
|
* - `Content-Security-Policy:`
|
||||||
|
* `default-src 'self'; script-src 'self'; style-src 'self';`
|
||||||
|
* `img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
||||||
|
* `frame-ancestors 'none'; base-uri 'self'; form-action 'self'`
|
||||||
|
*
|
||||||
|
* Construct with a `CspOverride` to relax individual directives without
|
||||||
|
* forking the interceptor — see the struct doc for the typical use.
|
||||||
*/
|
*/
|
||||||
class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor {
|
class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor {
|
||||||
|
private:
|
||||||
|
CspOverride m_override;
|
||||||
|
|
||||||
|
static const std::string& orDefault(const std::string& v, const std::string& fallback) {
|
||||||
|
return v.empty() ? fallback : v;
|
||||||
|
}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
SecurityHeadersInterceptor() = default;
|
||||||
|
explicit SecurityHeadersInterceptor(CspOverride override) : m_override(std::move(override)) {}
|
||||||
|
|
||||||
std::shared_ptr<OutgoingResponse> intercept(
|
std::shared_ptr<OutgoingResponse> intercept(
|
||||||
const std::shared_ptr<IncomingRequest>& request,
|
const std::shared_ptr<IncomingRequest>& request,
|
||||||
const std::shared_ptr<OutgoingResponse>& response) override {
|
const std::shared_ptr<OutgoingResponse>& response) override {
|
||||||
|
|
||||||
|
static const std::string DEF_DEFAULT = "'self'";
|
||||||
|
static const std::string DEF_SCRIPT = "'self'";
|
||||||
|
static const std::string DEF_STYLE = "'self'";
|
||||||
|
static const std::string DEF_IMG = "'self' data:";
|
||||||
|
static const std::string DEF_CONNECT = "'self'";
|
||||||
|
static const std::string DEF_FONT = "'self'";
|
||||||
|
static const std::string DEF_FRAME_ANC = "'none'";
|
||||||
|
static const std::string DEF_BASE = "'self'";
|
||||||
|
static const std::string DEF_FORM = "'self'";
|
||||||
|
static const std::string DEF_XFRAME = "DENY";
|
||||||
|
static const std::string DEF_PERMISSIONS =
|
||||||
|
"accelerometer=(), camera=(), geolocation=(), gyroscope=(),"
|
||||||
|
" magnetometer=(), microphone=(), payment=(), usb=()";
|
||||||
|
|
||||||
|
const std::string csp =
|
||||||
|
"default-src " + orDefault(m_override.defaultSrc, DEF_DEFAULT) + "; "
|
||||||
|
"script-src " + orDefault(m_override.scriptSrc, DEF_SCRIPT) + "; "
|
||||||
|
"style-src " + orDefault(m_override.styleSrc, DEF_STYLE) + "; "
|
||||||
|
"img-src " + orDefault(m_override.imgSrc, DEF_IMG) + "; "
|
||||||
|
"connect-src " + orDefault(m_override.connectSrc, DEF_CONNECT) + "; "
|
||||||
|
"font-src " + orDefault(m_override.fontSrc, DEF_FONT) + "; "
|
||||||
|
"frame-ancestors "+ orDefault(m_override.frameAncestors, DEF_FRAME_ANC) + "; "
|
||||||
|
"base-uri " + orDefault(m_override.baseUri, DEF_BASE) + "; "
|
||||||
|
"form-action " + orDefault(m_override.formAction, DEF_FORM);
|
||||||
|
|
||||||
response->putHeader("X-Content-Type-Options", "nosniff");
|
response->putHeader("X-Content-Type-Options", "nosniff");
|
||||||
response->putHeader("X-Frame-Options", "SAMEORIGIN");
|
response->putHeader("X-Frame-Options",
|
||||||
|
orDefault(m_override.xFrameOptions, DEF_XFRAME).c_str());
|
||||||
response->putHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
response->putHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
response->putHeader("Content-Security-Policy",
|
response->putHeader("Permissions-Policy",
|
||||||
"default-src 'self'; "
|
orDefault(m_override.permissionsPolicy, DEF_PERMISSIONS).c_str());
|
||||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; "
|
response->putHeader("Content-Security-Policy", csp.c_str());
|
||||||
"style-src 'self' 'unsafe-inline' https://unpkg.com; "
|
|
||||||
"img-src 'self' data: https:; "
|
if (m_override.sendHsts) {
|
||||||
"connect-src 'self' wss: ws:; "
|
const std::string hsts = m_override.hstsIncludeSubdomains
|
||||||
"font-src 'self'; "
|
? "max-age=63072000; includeSubDomains"
|
||||||
"frame-ancestors 'self'; "
|
: "max-age=63072000";
|
||||||
"base-uri 'self'; "
|
response->putHeader("Strict-Transport-Security", hsts.c_str());
|
||||||
"form-action 'self'");
|
}
|
||||||
response->putHeader("Strict-Transport-Security",
|
|
||||||
"max-age=63072000; includeSubDomains");
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,7 @@ add_test(NAME negotiation COMMAND test_negotiation)
|
||||||
add_executable(test_body_size_limit test_body_size_limit.cpp)
|
add_executable(test_body_size_limit test_body_size_limit.cpp)
|
||||||
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
|
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
add_test(NAME body_size_limit COMMAND test_body_size_limit)
|
add_test(NAME body_size_limit COMMAND test_body_size_limit)
|
||||||
|
|
||||||
|
add_executable(test_security_headers test_security_headers.cpp)
|
||||||
|
target_link_libraries(test_security_headers PRIVATE oatpp::authkit oatpp::oatpp)
|
||||||
|
add_test(NAME security_headers COMMAND test_security_headers)
|
||||||
|
|
|
||||||
41
test/test_security_headers.cpp
Normal file
41
test/test_security_headers.cpp
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Smoke test for SecurityHeadersInterceptor — confirms the header compiles
|
||||||
|
// in a consumer translation unit and the constructor surface matches the
|
||||||
|
// documented API. Behavioural tests against a real IncomingRequest /
|
||||||
|
// OutgoingResponse pair would need a full oatpp request fixture; pinning
|
||||||
|
// the API surface here is enough to catch the kinds of breakage this
|
||||||
|
// header is at risk of (struct field renames, accidental ctor changes).
|
||||||
|
|
||||||
|
#include "oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
using oatpp_authkit::CspOverride;
|
||||||
|
using oatpp_authkit::SecurityHeadersInterceptor;
|
||||||
|
|
||||||
|
// Default ctor: strict baseline.
|
||||||
|
auto strict = std::make_shared<SecurityHeadersInterceptor>();
|
||||||
|
(void)strict;
|
||||||
|
|
||||||
|
// Override ctor: every documented field reachable.
|
||||||
|
CspOverride o;
|
||||||
|
o.defaultSrc = "'self'";
|
||||||
|
o.scriptSrc = "'self' 'unsafe-inline'";
|
||||||
|
o.styleSrc = "'self' 'unsafe-inline'";
|
||||||
|
o.imgSrc = "'self' data: https:";
|
||||||
|
o.connectSrc = "'self' wss:";
|
||||||
|
o.fontSrc = "'self'";
|
||||||
|
o.frameAncestors = "'self'";
|
||||||
|
o.baseUri = "'self'";
|
||||||
|
o.formAction = "'self'";
|
||||||
|
o.sendHsts = false;
|
||||||
|
o.hstsIncludeSubdomains = true;
|
||||||
|
o.xFrameOptions = "SAMEORIGIN";
|
||||||
|
o.permissionsPolicy = "geolocation=(self)";
|
||||||
|
auto relaxed = std::make_shared<SecurityHeadersInterceptor>(std::move(o));
|
||||||
|
(void)relaxed;
|
||||||
|
|
||||||
|
std::printf("SecurityHeadersInterceptor API ok\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue