diff --git a/CMakeLists.txt b/CMakeLists.txt index 6a7f88d..805b301 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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: diff --git a/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp b/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp index 0e3a4cc..71ddada 100644 --- a/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp +++ b/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp @@ -3,37 +3,119 @@ #include "oatpp/web/server/interceptor/ResponseInterceptor.hpp" +#include + 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 intercept( const std::shared_ptr& request, const std::shared_ptr& 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; } }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e8a5692..80eb07f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/test_security_headers.cpp b/test/test_security_headers.cpp new file mode 100644 index 0000000..849be6b --- /dev/null +++ b/test/test_security_headers.cpp @@ -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 +#include + +int main() { + using oatpp_authkit::CspOverride; + using oatpp_authkit::SecurityHeadersInterceptor; + + // Default ctor: strict baseline. + auto strict = std::make_shared(); + (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(std::move(o)); + (void)relaxed; + + std::printf("SecurityHeadersInterceptor API ok\n"); + return 0; +}