#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)
|
||||
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
|
||||
# a CMake config package so consumers do:
|
||||
|
|
|
|||
|
|
@ -3,37 +3,119 @@
|
|||
|
||||
#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
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.
|
||||
*
|
||||
* Headers added:
|
||||
* - X-Content-Type-Options: nosniff — prevents MIME type sniffing
|
||||
* - X-Frame-Options: SAMEORIGIN — prevents clickjacking
|
||||
* - Referrer-Policy: strict-origin-when-cross-origin — limits referrer leakage
|
||||
* - Content-Security-Policy — restricts resource loading sources
|
||||
* Defaults track `docs/security-baseline.md`:
|
||||
* - `X-Content-Type-Options: nosniff`
|
||||
* - `X-Frame-Options: DENY`
|
||||
* - `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
* - `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 {
|
||||
private:
|
||||
CspOverride m_override;
|
||||
|
||||
static const std::string& orDefault(const std::string& v, const std::string& fallback) {
|
||||
return v.empty() ? fallback : v;
|
||||
}
|
||||
|
||||
public:
|
||||
SecurityHeadersInterceptor() = default;
|
||||
explicit SecurityHeadersInterceptor(CspOverride override) : m_override(std::move(override)) {}
|
||||
|
||||
std::shared_ptr<OutgoingResponse> intercept(
|
||||
const std::shared_ptr<IncomingRequest>& request,
|
||||
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-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("Content-Security-Policy",
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://unpkg.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://unpkg.com; "
|
||||
"img-src 'self' data: https:; "
|
||||
"connect-src 'self' wss: ws:; "
|
||||
"font-src 'self'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'");
|
||||
response->putHeader("Strict-Transport-Security",
|
||||
"max-age=63072000; includeSubDomains");
|
||||
response->putHeader("Permissions-Policy",
|
||||
orDefault(m_override.permissionsPolicy, DEF_PERMISSIONS).c_str());
|
||||
response->putHeader("Content-Security-Policy", csp.c_str());
|
||||
|
||||
if (m_override.sendHsts) {
|
||||
const std::string hsts = m_override.hstsIncludeSubdomains
|
||||
? "max-age=63072000; includeSubDomains"
|
||||
: "max-age=63072000";
|
||||
response->putHeader("Strict-Transport-Security", hsts.c_str());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,3 +13,7 @@ add_test(NAME negotiation COMMAND test_negotiation)
|
|||
add_executable(test_body_size_limit test_body_size_limit.cpp)
|
||||
target_link_libraries(test_body_size_limit PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
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