#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:
Uwe Schuster 2026-04-25 21:54:58 +02:00
parent bccd57f47e
commit 0d2312499e
4 changed files with 146 additions and 19 deletions

View file

@ -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:

View file

@ -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;
} }
}; };

View file

@ -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)

View 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;
}