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) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-21 21:42:53 +02:00
commit 32356ad226
11 changed files with 525 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
build/
*.swp
.DS_Store

46
CMakeLists.txt Normal file
View file

@ -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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
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)

46
README.md Normal file
View file

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

View file

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

48
docs/security-baseline.md Normal file
View file

@ -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/<name>/prod.env`.
## Session cookie (not in library v0.1 — convention documented here)
- `Set-Cookie: session=<token>; 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.

View file

@ -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<oatpp::web::protocol::http::outgoing::Response>
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<unsigned char>(*s) < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", static_cast<unsigned char>(*s));
out += buf;
} else {
out += *s;
}
}
}
return out;
}
};
#endif // HANDLER_JSON_ERROR_HANDLER_HPP

View file

@ -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<OutgoingResponse> intercept(const std::shared_ptr<IncomingRequest>& 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

View file

@ -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<OutgoingResponse> intercept(
const std::shared_ptr<IncomingRequest>& request,
const std::shared_ptr<OutgoingResponse>& 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

View file

@ -0,0 +1,52 @@
#ifndef OATPP_AUTHKIT_STARTUP_REQUIRE_ENCRYPTION_KEY_HPP
#define OATPP_AUTHKIT_STARTUP_REQUIRE_ENCRYPTION_KEY_HPP
#include <cstdlib>
#include <stdexcept>
#include <string>
#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

View file

@ -0,0 +1,85 @@
#ifndef UTIL_RATE_LIMITER_HPP
#define UTIL_RATE_LIMITER_HPP
#include <chrono>
#include <mutex>
#include <string>
#include <unordered_map>
/**
* @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<std::mutex> 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<double>(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<double>(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<std::string, Bucket> m_buckets;
};
#endif // UTIL_RATE_LIMITER_HPP

View file

@ -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 <arpa/inet.h>
#include <string>
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<IncomingRequest>& 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<IncomingRequest>& 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