commit 32356ad226293d26268c913222dcadd6dd540d70 Author: Uwe Schuster Date: Tue Apr 21 21:42:53 2026 +0200 v0.1.0: initial clean-lift from fewo-webapp Header-only C++ library; CMake config package; zero-coupling files lifted from fewo-webapp: interceptor/SecurityHeadersInterceptor.hpp interceptor/BodySizeLimitInterceptor.hpp handler/JsonErrorHandler.hpp util/RateLimiter.hpp util/TokenExtract.hpp (extractToken, isValidIp, clientIpTrusted) startup/RequireEncryptionKey.hpp fewo-specific couplings (bindAddress global, fewo::config) replaced with explicit function arguments so the library stands alone. AuthInterceptor + requireAdmin deferred to v0.2 — they need IAuthBackend / IAuthPolicy / IRuntimeConfig seams designed first. docs/security-baseline.md ships CSP / rate-limit / body-size / encryption key constants as language-neutral baselines for non-C++ consumers. Closes fewo-webapp#412 Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..beac2fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +*.swp +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e3a4eaa --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.14) +project(oatpp-authkit VERSION 0.1.0 LANGUAGES CXX) + +# Header-only interface library — no compilation, just an include path and +# a CMake config package so consumers do: +# find_package(oatpp-authkit REQUIRED) +# target_link_libraries(app PRIVATE oatpp::authkit) +# +# Or FetchContent: +# FetchContent_Declare(oatpp-authkit GIT_REPOSITORY ... GIT_TAG v0.1.0) +# FetchContent_MakeAvailable(oatpp-authkit) + +add_library(oatpp-authkit INTERFACE) +add_library(oatpp::authkit ALIAS oatpp-authkit) + +target_include_directories(oatpp-authkit INTERFACE + $ + $ +) +target_compile_features(oatpp-authkit INTERFACE cxx_std_17) + +# Installation +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(TARGETS oatpp-authkit EXPORT oatpp-authkit-targets) +install(EXPORT oatpp-authkit-targets + FILE oatpp-authkit-targets.cmake + NAMESPACE oatpp:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +configure_package_config_file( + cmake/oatpp-authkit-config.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/oatpp-authkit-config-version.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/oatpp-authkit) diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc385cb --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# oatpp-authkit + +Header-only C++ library distilled from [fewo-webapp](https://git.uwe-schuster.info/uwe.admin/fewo-webapp)'s +hardened auth / security stack. Header-only, oatpp 1.3+, C++17. + +## What's in v0.1 (the clean-lift set) + +| Header | Purpose | +|--------|---------| +| `interceptor/SecurityHeadersInterceptor.hpp` | CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy. Strict defaults. | +| `interceptor/BodySizeLimitInterceptor.hpp` | Reject request bodies above a configurable limit with 413 before they hit your handlers. | +| `handler/JsonErrorHandler.hpp` | Normalises thrown exceptions into `{status, message}` JSON so controllers never leak raw HTML error pages. | +| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). | +| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). | +| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. | + +## Consume via CMake + +```cmake +# FetchContent (pin to a tag): +include(FetchContent) +FetchContent_Declare(oatpp-authkit + GIT_REPOSITORY https://git.uwe-schuster.info/uwe.admin/oatpp-authkit.git + GIT_TAG v0.1.0) +FetchContent_MakeAvailable(oatpp-authkit) + +target_link_libraries(app PRIVATE oatpp::authkit) +``` + +Or after `cmake --install`: + +```cmake +find_package(oatpp-authkit 0.1 REQUIRED) +target_link_libraries(app PRIVATE oatpp::authkit) +``` + +## Roadmap + +- **v0.2** — `AuthInterceptor` + `requireAdmin` ported onto three seams + (`IAuthBackend`, `IAuthPolicy`, `IRuntimeConfig`) so consumers plug in their + own user store, public-path list, and admin role set without forking the + interceptor. +- **Later** — session cookie helpers, API-key rotation, re-encryption migration. + +See `docs/security-baseline.md` for language-neutral CSP / rate-limit / body-size +constants that non-C++ consumers can re-implement directly. diff --git a/cmake/oatpp-authkit-config.cmake.in b/cmake/oatpp-authkit-config.cmake.in new file mode 100644 index 0000000..e2f01ed --- /dev/null +++ b/cmake/oatpp-authkit-config.cmake.in @@ -0,0 +1,9 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +# oatpp headers are expected to be available through the consumer's own +# find_package(oatpp) — authkit is header-only and defers that to the app. + +include("${CMAKE_CURRENT_LIST_DIR}/oatpp-authkit-targets.cmake") + +check_required_components(oatpp-authkit) diff --git a/docs/security-baseline.md b/docs/security-baseline.md new file mode 100644 index 0000000..c5ea8d9 --- /dev/null +++ b/docs/security-baseline.md @@ -0,0 +1,48 @@ +# Security baseline — language-neutral constants + +These are the defaults enforced by the C++ interceptors and utilities. Consumers +on other stacks (including palibu's C backend, while it's still pre-migration) +should mirror the same values rather than re-deriving them. + +## Response headers (SecurityHeadersInterceptor) + +| Header | Value | Why | +|--------|-------|-----| +| `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'` | Strict by default. Opt in to `https://unpkg.com` only for the Swagger UI route on apps that serve it. | +| `X-Frame-Options` | `DENY` | Defence in depth against clickjacking (CSP `frame-ancestors` is primary). | +| `X-Content-Type-Options` | `nosniff` | | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | | +| `Permissions-Policy` | `accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()` | Disable sensors the apps don't need. | + +## Request limits (BodySizeLimitInterceptor) + +- **Max body size:** 1 MiB (1,048,576 bytes). Fail with HTTP 413 (`Payload Too Large`). +- **Paths exempt:** none in the baseline. Upload-heavy apps override per-route. + +## Rate limiting (RateLimiter) + +- **Key:** `clientIpTrusted(request, bindAddress)` — returns a validated IPv4/IPv6 + string, or `"invalid"`/`"unknown"`. Never attacker-chosen free-form text. +- **Default bucket:** 60 requests / 60 seconds per key. Tune per endpoint. +- **Loopback-only XFF trust:** `X-Forwarded-For` / `X-Real-IP` are honoured only + when the service binds to `127.0.0.1` / `::1` / `localhost`. On a public + interface we fall back to `"unknown"` (all untagged callers share one bucket, + which is stricter than letting forged headers dilute it). + +## Encryption key gate (startup::requireEncryptionKey) + +- **Required env var** (name is consumer-chosen; fewo uses `FEWO_ENCRYPTION_KEY`, + the scaffold standard is `APP_ENCRYPTION_KEY`): symmetric key, SHA-256 derives + the actual AES-256-GCM key. +- **Startup behaviour:** missing key + no `--allow-plaintext` dev flag ⇒ refuse + to start (throws). Missing key + `--allow-plaintext` ⇒ WARN and continue with + plaintext storage. +- **Rationale:** prevents PII / credentials silently landing in plaintext + production databases when an operator forgets to populate `/etc//prod.env`. + +## Session cookie (not in library v0.1 — convention documented here) + +- `Set-Cookie: session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000` + (30 days). Add `Secure` when serving behind TLS. +- Token: 32 random bytes base64url-encoded. Stored in the session table hashed + (SHA-256) so DB compromise doesn't yield session hijacking. diff --git a/include/oatpp-authkit/handler/JsonErrorHandler.hpp b/include/oatpp-authkit/handler/JsonErrorHandler.hpp new file mode 100644 index 0000000..04a1f4e --- /dev/null +++ b/include/oatpp-authkit/handler/JsonErrorHandler.hpp @@ -0,0 +1,66 @@ +#ifndef HANDLER_JSON_ERROR_HANDLER_HPP +#define HANDLER_JSON_ERROR_HANDLER_HPP + +#include "oatpp/web/server/handler/ErrorHandler.hpp" +#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp" + +/** + * @brief Custom error handler that returns JSON error responses. + * + * Replaces oatpp's default plain-text error handler so that + * OATPP_ASSERT_HTTP errors are returned as JSON objects matching + * the StatusDto schema: {"status": "...", "code": N, "message": "..."}. + * This allows the frontend's coreFetch to parse error details reliably. + */ +class JsonErrorHandler : public oatpp::web::server::handler::ErrorHandler { +public: + + std::shared_ptr + handleError(const oatpp::web::protocol::http::Status& status, + const oatpp::String& message, + const Headers& headers) override + { + auto json = oatpp::String( + "{\"status\":\"" + std::string(status.description) + + "\",\"code\":" + std::to_string(status.code) + + ",\"message\":\"" + escapeJson(message ? message->c_str() : "") + "\"}" + ); + + auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse( + status, json + ); + response->putHeader("Content-Type", "application/json"); + + for (const auto& pair : headers.getAll()) { + response->putHeader(pair.first.toString(), pair.second.toString()); + } + + return response; + } + +private: + + static std::string escapeJson(const char* s) { + std::string out; + for (; *s; ++s) { + switch (*s) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(*s) < 0x20) { + char buf[8]; + snprintf(buf, sizeof(buf), "\\u%04x", static_cast(*s)); + out += buf; + } else { + out += *s; + } + } + } + return out; + } +}; + +#endif // HANDLER_JSON_ERROR_HANDLER_HPP diff --git a/include/oatpp-authkit/interceptor/BodySizeLimitInterceptor.hpp b/include/oatpp-authkit/interceptor/BodySizeLimitInterceptor.hpp new file mode 100644 index 0000000..47cdd34 --- /dev/null +++ b/include/oatpp-authkit/interceptor/BodySizeLimitInterceptor.hpp @@ -0,0 +1,43 @@ +#ifndef BodySizeLimitInterceptor_hpp +#define BodySizeLimitInterceptor_hpp + +#include "oatpp/web/server/interceptor/RequestInterceptor.hpp" +#include "oatpp/web/protocol/http/outgoing/ResponseFactory.hpp" + +/** + * @brief Request interceptor that rejects requests exceeding a body size limit. + * + * Checks the Content-Length header and returns HTTP 413 (Payload Too Large) + * if the declared body size exceeds the configured maximum. + */ +class BodySizeLimitInterceptor : public oatpp::web::server::interceptor::RequestInterceptor { +private: + size_t m_maxBytes; + +public: + /** + * @param maxBytes Maximum allowed request body size in bytes. + */ + explicit BodySizeLimitInterceptor(size_t maxBytes) : m_maxBytes(maxBytes) {} + + std::shared_ptr intercept(const std::shared_ptr& request) override { + auto contentLength = request->getHeader("Content-Length"); + if (contentLength && !contentLength->empty()) { + try { + size_t len = std::stoull(std::string(*contentLength)); + if (len > m_maxBytes) { + auto response = oatpp::web::protocol::http::outgoing::ResponseFactory::createResponse( + oatpp::web::protocol::http::Status(413, "Payload Too Large"), + "{\"status\":\"Payload Too Large\"}"); + response->putHeader("Content-Type", "application/json"); + return response; + } + } catch (...) { + // Malformed Content-Length — let it through, Oat++ will handle it + } + } + return nullptr; // pass through + } +}; + +#endif diff --git a/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp b/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp new file mode 100644 index 0000000..ddeb5a5 --- /dev/null +++ b/include/oatpp-authkit/interceptor/SecurityHeadersInterceptor.hpp @@ -0,0 +1,39 @@ +#ifndef SecurityHeadersInterceptor_hpp +#define SecurityHeadersInterceptor_hpp + +#include "oatpp/web/server/interceptor/ResponseInterceptor.hpp" + +/** + * @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 + */ +class SecurityHeadersInterceptor : public oatpp::web::server::interceptor::ResponseInterceptor { +public: + std::shared_ptr intercept( + const std::shared_ptr& request, + const std::shared_ptr& response) override { + response->putHeader("X-Content-Type-Options", "nosniff"); + response->putHeader("X-Frame-Options", "SAMEORIGIN"); + 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"); + return response; + } +}; + +#endif diff --git a/include/oatpp-authkit/startup/RequireEncryptionKey.hpp b/include/oatpp-authkit/startup/RequireEncryptionKey.hpp new file mode 100644 index 0000000..5e81596 --- /dev/null +++ b/include/oatpp-authkit/startup/RequireEncryptionKey.hpp @@ -0,0 +1,52 @@ +#ifndef OATPP_AUTHKIT_STARTUP_REQUIRE_ENCRYPTION_KEY_HPP +#define OATPP_AUTHKIT_STARTUP_REQUIRE_ENCRYPTION_KEY_HPP + +#include +#include +#include + +#include "oatpp/core/base/Environment.hpp" + +namespace oatpp_authkit { + +/** + * @brief Enforce that a symmetric encryption key is present at startup. + * + * Call once during App init, after your CryptoService has had a chance to + * consume the env var. If the key is missing AND the `allowPlaintext` dev + * override was NOT passed, throws std::runtime_error so the process refuses + * to start. This prevents PII / credentials silently landing in plaintext. + * + * @param envVarName Name of the env var holding the raw key (e.g. + * "APP_ENCRYPTION_KEY"). Used only for log messages. + * @param encryptionEnabled Whether the caller's CryptoService / KMS has a + * key loaded — checked by the caller, passed in. + * @param allowPlaintext Dev-only escape hatch. When true, logs a WARN and + * returns normally; when false, throws on a missing key. + * + * Lifted from fewo-webapp's App.cpp (#401). Kept as a pure function so tests + * can exercise both branches without spinning up a full oatpp runtime. + */ +inline void requireEncryptionKey(const std::string& envVarName, + bool encryptionEnabled, + bool allowPlaintext) +{ + if (encryptionEnabled) return; + + if (allowPlaintext) { + OATPP_LOGW("authkit", "%s not set — data will be stored in plaintext. " + "--allow-plaintext is for development only.", + envVarName.c_str()); + return; + } + + OATPP_LOGE("authkit", "%s not set — refusing to start with PII/credentials " + "in plaintext. Set the env var or pass --allow-plaintext " + "(dev only).", + envVarName.c_str()); + throw std::runtime_error(envVarName + " required"); +} + +} // namespace oatpp_authkit + +#endif diff --git a/include/oatpp-authkit/util/RateLimiter.hpp b/include/oatpp-authkit/util/RateLimiter.hpp new file mode 100644 index 0000000..ace4555 --- /dev/null +++ b/include/oatpp-authkit/util/RateLimiter.hpp @@ -0,0 +1,85 @@ +#ifndef UTIL_RATE_LIMITER_HPP +#define UTIL_RATE_LIMITER_HPP + +#include +#include +#include +#include + +/** + * @brief Per-key token bucket rate limiter. + * + * Each unique key (typically a client IP) gets its own bucket that refills + * at a steady rate up to a maximum capacity. Thread-safe. + * + * Lazy eviction (#391): when the bucket map exceeds 10,000 entries, expired + * buckets (tokens fully refilled = idle for capacity/refillRate seconds) + * are removed to prevent unbounded memory growth. + * + * Usage: + * RateLimiter limiter(30.0, 0.5); // 30 burst, 0.5 tokens/sec + * if (!limiter.allow("192.168.1.1")) { ... // reject } + */ +class RateLimiter { +public: + /** + * @param capacity Maximum burst size (tokens). + * @param refillRate Tokens added per second. + */ + RateLimiter(double capacity, double refillRate) + : m_capacity(capacity), m_refillRate(refillRate) {} + + /** @brief Try to consume one token for the given key. Returns true if allowed. */ + bool allow(const std::string& key) { + std::lock_guard lk(m_mutex); + + // Lazy eviction: sweep expired entries when map grows too large (#391) + if (m_buckets.size() > 10000) { + evictExpired(); + } + + auto& bucket = m_buckets[key]; + if (!bucket.initialized) { + bucket.tokens = m_capacity; + bucket.lastRefill = std::chrono::steady_clock::now(); + bucket.initialized = true; + } + auto now = std::chrono::steady_clock::now(); + double secs = std::chrono::duration(now - bucket.lastRefill).count(); + bucket.tokens = std::min(m_capacity, bucket.tokens + secs * m_refillRate); + bucket.lastRefill = now; + if (bucket.tokens >= 1.0) { + bucket.tokens -= 1.0; + return true; + } + return false; + } + +private: + struct Bucket { + double tokens = 0.0; + std::chrono::steady_clock::time_point lastRefill; + bool initialized = false; + }; + + /** @brief Remove entries that have been idle long enough to fully refill (called under lock). */ + void evictExpired() { + auto now = std::chrono::steady_clock::now(); + double fullRefillSecs = m_refillRate > 0 ? m_capacity / m_refillRate : 60.0; + for (auto it = m_buckets.begin(); it != m_buckets.end(); ) { + double secs = std::chrono::duration(now - it->second.lastRefill).count(); + if (secs >= fullRefillSecs) { + it = m_buckets.erase(it); + } else { + ++it; + } + } + } + + double m_capacity; + double m_refillRate; + std::mutex m_mutex; + std::unordered_map m_buckets; +}; + +#endif // UTIL_RATE_LIMITER_HPP diff --git a/include/oatpp-authkit/util/TokenExtract.hpp b/include/oatpp-authkit/util/TokenExtract.hpp new file mode 100644 index 0000000..50a4ca7 --- /dev/null +++ b/include/oatpp-authkit/util/TokenExtract.hpp @@ -0,0 +1,88 @@ +#ifndef OATPP_AUTHKIT_UTIL_TOKEN_EXTRACT_HPP +#define OATPP_AUTHKIT_UTIL_TOKEN_EXTRACT_HPP + +#include "oatpp/web/server/api/ApiController.hpp" +#include +#include + +namespace oatpp_authkit { + +using IncomingRequest = oatpp::web::protocol::http::incoming::Request; + +/** + * @brief Pull the session token from an incoming request. + * + * Order of precedence: `Cookie: session=...` → `Authorization: Bearer ...`. + * Returns "" when no token is present. Does not validate the token — callers + * hash it and look it up in their session store. + */ +inline std::string extractToken(const std::shared_ptr& request) { + auto cookie = request->getHeader("Cookie"); + if (cookie && !cookie->empty()) { + const std::string& c = *cookie; + auto pos = c.find("session="); + if (pos != std::string::npos) { + pos += 8; + auto end = c.find(';', pos); + return end == std::string::npos ? c.substr(pos) : c.substr(pos, end - pos); + } + } + auto auth = request->getHeader("Authorization"); + if (auth && !auth->empty()) { + const std::string& a = *auth; + if (a.size() > 7 && a.substr(0, 7) == "Bearer ") return a.substr(7); + } + return ""; +} + +/** @brief True iff `s` parses as IPv4 or IPv6 via inet_pton. */ +inline bool isValidIp(const std::string& s) { + if (s.empty() || s.size() > 45) return false; + unsigned char buf[16]; + if (inet_pton(AF_INET, s.c_str(), buf) == 1) return true; + if (inet_pton(AF_INET6, s.c_str(), buf) == 1) return true; + return false; +} + +/** + * @brief Extract the caller's IP — only trusts X-Forwarded-For / X-Real-IP + * when we're bound to loopback (i.e., an ingress proxy is the only + * possible source of that header). + * + * Returns a validated IPv4/IPv6 string, "invalid" when a forwarded header is + * present but malformed, or "unknown" when we can't determine it safely. + * Never returns attacker-chosen free-form text — the result is safe to use + * as a rate-limit key or to log to fail2ban. + * + * The `bindAddress` argument carries the host the service is listening on; + * pass your runtime config value here. + */ +inline std::string clientIpTrusted( + const std::shared_ptr& req, + const std::string& bindAddress) +{ + const bool ingressIsProxy = + bindAddress == "127.0.0.1" || bindAddress == "::1" || bindAddress == "localhost"; + + if (ingressIsProxy) { + auto xff = req->getHeader("X-Forwarded-For"); + if (xff && !xff->empty()) { + std::string s(*xff); + auto comma = s.find(','); + std::string first = (comma == std::string::npos) ? s : s.substr(0, comma); + while (!first.empty() && (first.front() == ' ' || first.front() == '\t')) first.erase(first.begin()); + while (!first.empty() && (first.back() == ' ' || first.back() == '\t')) first.pop_back(); + return isValidIp(first) ? first : "invalid"; + } + auto xr = req->getHeader("X-Real-IP"); + if (xr && !xr->empty()) { + std::string s(*xr); + return isValidIp(s) ? s : "invalid"; + } + } + return "unknown"; +} + +} // namespace oatpp_authkit + +#endif