#16 (audit M-1..M-12): fix the medium-severity findings

M-1  TokenExtract: exact-name cookie parse (new pure cookieValue helper) —
     a substring find("session=") could be shadowed by a sibling xsession=,
     defeating __Host-/__Secure- prefix guarantees.
M-2  AuthInterceptor: gate setup-mode pseudo-admin on a loopback bind and log
     the grant; document that IAuthBackend::hasActiveUsers() must fail closed.
M-3  ws/Hub: empty propertyIds now means NO access for non-admins (was "all") —
     a non-admin whose scope set failed to populate no longer gets every
     property's notifications. Admins still get all via role.
M-4  new util/OriginCheck.hpp (originHostname/sameOrigin/originAllowed) +
     Hub doc: WSController must validate Origin at the handshake (CSWSH).
M-6  RedactedFieldRepository: ctor throws on an unknown redaction field name
     (a typo would silently redact nothing, leaving credentials in history).
M-7  RateLimiter: ctor validates capacity (finite >=1) / refillRate (finite >0),
     throws std::invalid_argument — zero/negative/NaN silently disabled it.
M-8  TokenExtract: document that clientIpTrusted's "unknown"/"invalid" sentinels
     collapse to one shared rate-limit bucket off-proxy.
M-9  new util/SessionCookie.hpp: safe-by-default Set-Cookie builder
     (HttpOnly+Secure+SameSite=Strict+Path=/), rejects control chars / ';'.
M-10 AuthInterceptor: Origin/Referer-vs-Host check on session mutations
     (defence in depth atop X-Requested-With); cert path documented as
     non-browser / not CSRF-gated.
M-11 AuthInterceptor: optional injected RateLimiter throttles invalid-token
     attempts per client IP → 429.
M-12 AuthInterceptor: sanitize request method/path (strip control chars, cap
     length) before logging — closes log-line forging (CWE-117).

(M-5 — temporal non-atomic save — was already resolved by the H-4 fix.)

Tests: new test_token_extract / test_rate_limiter / test_origin_check /
test_session_cookie; extended test_redacted_field_repository. All 19 ctest
targets pass. README + header docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-29 13:53:22 +02:00
parent 2e11408240
commit fafee1278f
15 changed files with 637 additions and 26 deletions

View file

@ -10,8 +10,10 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `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). |
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). The constructor validates its args (`capacity` finite ≥1, `refillRate` finite >0) and throws `std::invalid_argument` otherwise — a zero/negative/NaN rate previously disabled the limiter silently (authkit#16 M-7). |
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer) + `cookieValue(header,name)` exact-name cookie parse (authkit#16 M-1 — no substring matching, so a sibling `xsession=` can't shadow `session=`), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF; returns the `"unknown"`/`"invalid"` sentinels off-proxy — treat as one shared rate-limit bucket, M-8). |
| `util/OriginCheck.hpp` | `originHostname`, `sameOrigin(originOrReferer, host)`, `originAllowed(origin, allowlist)` — pure CSRF/CSWSH origin helpers (authkit#16 M-4/M-10). Used by `AuthInterceptor` for session mutations; call `sameOrigin`/`originAllowed` in your WSController to block Cross-Site WebSocket Hijacking at the handshake. |
| `util/SessionCookie.hpp` | `buildSetSessionCookie(token, opts)` / `buildClearSessionCookie(opts)` — safe-by-default `Set-Cookie` builder (HttpOnly + Secure + SameSite=Strict + Path=/ by default; opt out explicitly). Rejects control chars / `;` in fields (authkit#16 M-9). Returns the header value only; framework-agnostic. |
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `TemporalFieldTraits<T>` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. |
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. |
@ -19,7 +21,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. Wrap a scope-guarded `IQueryable` with `ScopeGuardQueryable<T>` (not the plain `ScopeGuardRepository`) so `query()` is scope-filtered. |
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}``Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
| `repo/SchemaContract.hpp` | Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a `static constexpr DecoratorSchema kSchema` listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. `SchemaBuilder<Decorators…>::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract<Decorators…>::verify(table, probe)` is a runtime introspect-and-assert that throws `SchemaContractViolation` if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it. |
| `repo/RedactedFieldRepository.hpp` | Decorator that nulls out named fields on **historical** rows only (authkit#15). Sits below `TemporalRepository` and inspects each `save`: if `valid_until != SENTINEL`, the row is being closed as a historical version, so the configured fields (e.g. `passwordHash`, `tlsCertDn`) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. |
| `repo/RedactedFieldRepository.hpp` | Decorator that nulls out named fields on **historical** rows only (authkit#15). Sits below `TemporalRepository` and inspects each `save`: if `valid_until != SENTINEL`, the row is being closed as a historical version, so the configured fields (e.g. `passwordHash`, `tlsCertDn`) are set to null before persisting. The live row keeps its values intact. Built for the case where a credential rides a temporal row — every change creates a historical version with the prior secret preserved, and the redaction prevents a DB breach from yielding every credential a user has ever had. The constructor throws `std::invalid_argument` if a configured field name isn't a DTO member (authkit#16 M-6) — a typo would otherwise silently redact nothing. |
## Decorator schema contributions

View file

@ -16,6 +16,8 @@
#include "IAuthPolicy.hpp"
#include "IRuntimeConfig.hpp"
#include "../util/TokenExtract.hpp"
#include "../util/OriginCheck.hpp"
#include "../util/RateLimiter.hpp"
#include "../dto/InternalDto.hpp"
namespace oatpp_authkit {
@ -28,10 +30,12 @@ using TokenHasher = std::function<std::string(const std::string&)>;
*
* Order of checks:
* 1. Public path pass.
* 2. Setup mode (empty users table + policy->setupModeActive()) pseudo-admin.
* 2. Setup mode (empty users table + policy->setupModeActive() + loopback bind) pseudo-admin.
* 3. X-SSL-Client-DN header (only trusted when `IRuntimeConfig::certAuthTrusted()`) cert auth.
* 4. Session cookie / Bearer token backend->resolveBySessionHash / resolveByApiKeyHash.
* 5. CSRF defence: sessions reject state-changing requests without X-Requested-With.
* (Invalid tokens are optionally per-IP rate-limited 429 when a RateLimiter is supplied.)
* 5. CSRF defence (session cookie + mutation): require X-Requested-With AND,
* when present, an Origin/Referer whose host matches the request Host.
* 6. Readonly roles cannot mutate.
*
* Bundle data written on success (consumed by requireAdmin / requireUser):
@ -46,6 +50,7 @@ private:
std::shared_ptr<IRuntimeConfig> m_runtime;
TokenHasher m_hashToken;
std::shared_ptr<oatpp::data::mapping::ObjectMapper> m_mapper;
std::shared_ptr<RateLimiter> m_authLimiter; ///< Optional (authkit#16 M-11): throttles invalid-token attempts per client IP.
using Status = oatpp::web::protocol::http::Status;
using ResponseFactory = oatpp::web::protocol::http::outgoing::ResponseFactory;
@ -175,10 +180,25 @@ private:
req->putBundleData("auth_username", oatpp::String(p.username.c_str()));
}
/** @brief Neutralise control characters before logging (authkit#16 M-12).
* The request path/method are attacker-controlled; a raw CR/LF in the
* request target would otherwise forge log lines (CWE-117). */
static std::string sanitizeForLog(const std::string& s) {
std::string out;
const std::size_t cap = 256;
out.reserve(s.size() < cap ? s.size() : cap);
for (unsigned char c : s) {
if (out.size() >= cap) break;
out.push_back((c < 0x20 || c == 0x7f) ? '?' : static_cast<char>(c));
}
return out;
}
static void logEvent(int status, const std::string& method,
const std::string& path, const std::string& reason) {
OATPP_LOGW("authkit", "[%d] %s %s — %s",
status, method.c_str(), path.c_str(), reason.c_str());
status, sanitizeForLog(method).c_str(),
sanitizeForLog(path).c_str(), reason.c_str());
}
bool isMutation(const std::string& method) {
@ -193,12 +213,14 @@ public:
std::shared_ptr<IAuthPolicy> policy,
std::shared_ptr<IRuntimeConfig> runtime,
TokenHasher hashToken,
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr)
std::shared_ptr<oatpp::data::mapping::ObjectMapper> mapper = nullptr,
std::shared_ptr<RateLimiter> authRateLimiter = nullptr)
: m_backend(std::move(backend))
, m_policy(std::move(policy))
, m_runtime(std::move(runtime))
, m_hashToken(std::move(hashToken))
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared()) {}
, m_mapper(mapper ? mapper : oatpp::parser::json::mapping::ObjectMapper::createShared())
, m_authLimiter(std::move(authRateLimiter)) {}
std::shared_ptr<OutgoingResponse> intercept(
const std::shared_ptr<IncomingRequest>& request) override
@ -223,8 +245,15 @@ public:
if (m_policy->isPublicPath(path)) return nullptr;
// Setup mode: empty users + policy opts in → pseudo-admin.
if (m_policy->setupModeActive() && !m_backend->hasActiveUsers()) {
// Setup mode: empty users + policy opts in + loopback bind → pseudo-admin.
// authkit#16 M-2: gate on isLoopback() so a stray SETUP_MODE sentinel can
// never expose anonymous admin on a public bind, and log the grant (it
// was previously silent). hasActiveUsers() must fail closed (see
// IAuthBackend) — a swallowed DB error returning false would otherwise
// open the entire API.
if (m_policy->setupModeActive() && m_runtime->isLoopback()
&& !m_backend->hasActiveUsers()) {
logEvent(200, method, path, "setup-mode pseudo-admin granted (no users yet)");
AuthPrincipal p{0, "setup", "admin"};
writeBundle(request, p);
return nullptr;
@ -234,6 +263,12 @@ public:
// `certAuthTrusted()` defaults to `false` (fail closed); consumers must
// opt in explicitly and only behind a proxy that strips the inbound
// `X-SSL-Client-DN` header and re-sets it from a verified client cert.
//
// authkit#16 M-10: the cert path is deliberately NOT CSRF-gated. CSRF is
// a browser-cookie problem; cert auth is for non-browser / mTLS clients
// that don't auto-attach an ambient credential, so `X-Requested-With` /
// Origin checks don't apply. Do not expose cert auth to cookie-bearing
// browser sessions.
auto certDnH = request->getHeader("X-SSL-Client-DN");
if (m_runtime->certAuthTrusted() && certDnH && !certDnH->empty()) {
if (auto p = m_backend->resolveByCertDn(std::string(*certDnH))) {
@ -261,6 +296,16 @@ public:
} else if ((p = m_backend->resolveByApiKeyHash(hash))) {
viaSession = false;
} else {
// authkit#16 M-11: when an optional limiter is wired in, throttle
// repeated invalid-token submissions per client IP (token guessing /
// credential stuffing) and answer 429 before the 401.
if (m_authLimiter) {
const std::string ip = clientIpTrusted(request, m_runtime->bindAddress());
if (!m_authLimiter->allow("authfail:" + ip)) {
logEvent(429, method, path, "auth rate limit (invalid token)");
return makeJsonError(Status::CODE_429, "Too Many Requests", "");
}
}
logEvent(401, method, path, "invalid token");
return makeUnauthorized(request, path);
}
@ -272,6 +317,28 @@ public:
logEvent(403, method, path, "missing X-Requested-With");
return makeForbidden(request, path, "Missing X-Requested-With header");
}
// authkit#16 M-10: second CSRF layer — when an Origin (or, failing
// that, Referer) header is present on a cookie-auth mutation, its
// host must match the request Host. Catches cross-site forgeries
// even if a permissive CORS policy ever lets X-Requested-With
// through. Compared by hostname (port/scheme ignored) to stay
// correct behind a TLS-terminating proxy; when neither header is
// present we fall back to the X-Requested-With guarantee above.
auto host = request->getHeader("Host");
auto origin = request->getHeader("Origin");
auto referer = request->getHeader("Referer");
const std::string hostStr = host ? std::string(*host) : std::string();
if (origin && !origin->empty()) {
if (!sameOrigin(std::string(*origin), hostStr)) {
logEvent(403, method, path, "Origin/Host mismatch");
return makeForbidden(request, path, "Cross-origin request rejected");
}
} else if (referer && !referer->empty()) {
if (!sameOrigin(std::string(*referer), hostStr)) {
logEvent(403, method, path, "Referer/Host mismatch");
return makeForbidden(request, path, "Cross-origin request rejected");
}
}
}
writeBundle(request, *p);

View file

@ -38,7 +38,16 @@ public:
return std::nullopt;
}
/** @brief True iff at least one active user exists. Used for setup-mode gate. */
/** @brief True iff at least one active user exists. Used for setup-mode gate.
*
* @warning Must FAIL CLOSED (authkit#16 M-2): on any uncertainty a DB
* error, a timeout, an empty result you can't trust return
* `true` (or throw), never `false`. A `false` returned on a
* swallowed error opens the setup-mode pseudo-admin path and
* grants unauthenticated admin to every request. The interceptor
* additionally gates setup mode on a loopback bind, but the
* authoritative "are we still in first-run setup?" answer is
* yours and must not degrade open. */
virtual bool hasActiveUsers() = 0;
/** @brief Delete expired session rows. Called periodically by the interceptor. */

View file

@ -32,6 +32,7 @@
#include "oatpp/core/Types.hpp"
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
@ -66,7 +67,27 @@ public:
RedactedFieldRepository(std::shared_ptr<Repository<TDto>> inner,
std::vector<std::string> fieldsToRedact)
: m_inner(std::move(inner))
, m_fieldsToRedact(std::move(fieldsToRedact)) {}
, m_fieldsToRedact(std::move(fieldsToRedact))
{
// authkit#16 M-6: fail loud if a configured field name doesn't exist on
// the DTO. A typo (or passing the JSON column name instead of the C++
// identifier) would otherwise silently redact nothing, leaving the
// credential in history — the exact breach this decorator prevents.
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (const auto& target : m_fieldsToRedact) {
bool found = false;
for (auto* p : dispatcher->getProperties()->getList()) {
if (target == p->name) { found = true; break; }
}
if (!found) {
throw std::invalid_argument(
"RedactedFieldRepository: unknown DTO field '" + target +
"' (use the C++ identifier from DTO_FIELD, not the JSON name)");
}
}
}
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
return m_inner->findByEntityId(entityId);

View file

@ -0,0 +1,73 @@
#ifndef OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
#define OATPP_AUTHKIT_UTIL_ORIGIN_CHECK_HPP
// Origin / Referer validation helpers (authkit#16 M-4, M-10).
//
// Pure, dependency-free string helpers for CSRF defence-in-depth and for
// WebSocket Cross-Site-WebSocket-Hijacking (CSWSH) protection. The library
// can't enforce these everywhere — the WS upgrade decision lives in the
// consumer's WSController — so these primitives let consumers do the check
// at the right point, and `AuthInterceptor` uses them for session mutations.
#include <algorithm>
#include <cctype>
#include <string>
#include <vector>
namespace oatpp_authkit {
/**
* @brief Extract the lowercased hostname from an `Origin` / `Referer` value or
* a `Host` header. Strips scheme, port, path and query.
*
* "https://app.example.com:8443/x?y" "app.example.com"
* "app.example.com:443" "app.example.com"
*/
inline std::string originHostname(const std::string& v) {
std::string s = v;
auto scheme = s.find("://");
if (scheme != std::string::npos) s = s.substr(scheme + 3);
auto slash = s.find('/');
if (slash != std::string::npos) s = s.substr(0, slash);
auto colon = s.find(':');
if (colon != std::string::npos) s = s.substr(0, colon);
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
/**
* @brief Same-origin check by hostname: the `Origin` (or `Referer`) host must
* equal the request `Host` host. Port/scheme are intentionally ignored
* to avoid false positives behind TLS-terminating reverse proxies
* (Origin omits the default port; Host may or may not carry one) a
* cross-*host* request is the unambiguous CSRF/CSWSH signal.
*
* Returns `true` (don't block) when either input is empty the caller can't
* decide and should fall back to another control (e.g. `X-Requested-With`).
*/
inline bool sameOrigin(const std::string& originOrReferer, const std::string& hostHeader) {
if (originOrReferer.empty() || hostHeader.empty()) return true;
return originHostname(originOrReferer) == originHostname(hostHeader);
}
/**
* @brief Allowlist check: the `Origin` host must be one of `allowedHosts`
* (each compared by hostname via `originHostname`). Use for WS upgrades
* when the allowed origins aren't simply "same host as the request".
*
* Returns `false` for an empty / unparseable origin i.e. fail closed.
*/
inline bool originAllowed(const std::string& origin, const std::vector<std::string>& allowedHosts) {
if (origin.empty()) return false;
const std::string h = originHostname(origin);
if (h.empty()) return false;
for (const auto& a : allowedHosts) {
if (originHostname(a) == h) return true;
}
return false;
}
} // namespace oatpp_authkit
#endif

View file

@ -2,7 +2,9 @@
#define UTIL_RATE_LIMITER_HPP
#include <chrono>
#include <cmath>
#include <mutex>
#include <stdexcept>
#include <string>
#include <unordered_map>
@ -25,11 +27,22 @@ namespace oatpp_authkit {
class RateLimiter {
public:
/**
* @param capacity Maximum burst size (tokens).
* @param refillRate Tokens added per second.
* @param capacity Maximum burst size (tokens). Must be finite and >= 1.
* @param refillRate Tokens added per second. Must be finite and > 0.
*
* @throws std::invalid_argument on non-finite / out-of-range values
* (authkit#16 M-7). A zero/negative `refillRate` previously made
* every bucket evict on each sweep (limiter silently disabled
* brute-force bypass), and NaN made `allow()` reject everything
* (DoS). Fail loud at construction instead.
*/
RateLimiter(double capacity, double refillRate)
: m_capacity(capacity), m_refillRate(refillRate) {}
: m_capacity(capacity), m_refillRate(refillRate) {
if (!std::isfinite(capacity) || capacity < 1.0)
throw std::invalid_argument("RateLimiter: capacity must be finite and >= 1");
if (!std::isfinite(refillRate) || refillRate <= 0.0)
throw std::invalid_argument("RateLimiter: refillRate must be finite and > 0");
}
/** @brief Try to consume one token for the given key. Returns true if allowed. */
bool allow(const std::string& key) {

View file

@ -0,0 +1,65 @@
#ifndef OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
#define OATPP_AUTHKIT_UTIL_SESSION_COOKIE_HPP
// Safe-by-default Set-Cookie builder for session tokens (authkit#16 M-9).
//
// The library reads the session cookie (util/TokenExtract.hpp) but previously
// shipped no helper to *write* it, so every consumer hand-rolled `Set-Cookie`
// and the security attributes (HttpOnly / Secure / SameSite) were easy to
// forget. This builder defaults to the hardened set; opt OUT explicitly.
//
// Returns the header *value* only (decoupled from any HTTP framework) — the
// consumer sets it via e.g. `response->putHeader("Set-Cookie", value)`.
#include <stdexcept>
#include <string>
namespace oatpp_authkit {
/** @brief Set-Cookie attributes. Defaults are the hardened set. */
struct SessionCookieOptions {
std::string name = "session"; ///< Cookie name. For `__Host-` prefix guarantees, set "__Host-session" (requires secure=true, path="/", no domain).
bool httpOnly = true; ///< Block JS access (document.cookie).
bool secure = true; ///< HTTPS-only. Leave true in prod; only disable for plaintext dev.
std::string sameSite = "Strict"; ///< "Strict" | "Lax" | "None" (""=omit). "None" requires secure=true per spec.
std::string path = "/";
long maxAgeSeconds = -1; ///< <0 ⇒ session cookie (no Max-Age); 0 ⇒ expire now (clear).
};
/**
* @brief Build a `Set-Cookie` header value for a session token.
* @throws std::invalid_argument if `token`, `name` or `path` contain control
* characters / `;` (header/cookie-injection guard).
*/
inline std::string buildSetSessionCookie(const std::string& token,
const SessionCookieOptions& opt = {}) {
auto reject = [](const std::string& s) {
for (unsigned char c : s)
if (c < 0x20 || c == 0x7f || c == ';') return true;
return false;
};
if (reject(token) || reject(opt.name) || reject(opt.path))
throw std::invalid_argument("buildSetSessionCookie: control char / ';' in cookie field");
std::string c = opt.name + "=" + token;
if (!opt.path.empty()) c += "; Path=" + opt.path;
if (opt.maxAgeSeconds >= 0) c += "; Max-Age=" + std::to_string(opt.maxAgeSeconds);
if (opt.httpOnly) c += "; HttpOnly";
if (opt.secure) c += "; Secure";
if (!opt.sameSite.empty()) c += "; SameSite=" + opt.sameSite;
return c;
}
/**
* @brief Build a `Set-Cookie` value that clears the session cookie (logout).
* Same attributes as the original so the browser matches and removes it.
*/
inline std::string buildClearSessionCookie(const SessionCookieOptions& opt = {}) {
SessionCookieOptions o = opt;
o.maxAgeSeconds = 0;
return buildSetSessionCookie("", o);
}
} // namespace oatpp_authkit
#endif

View file

@ -9,6 +9,44 @@ namespace oatpp_authkit {
using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
/**
* @brief Read the value of an exact-named cookie from a `Cookie` header.
*
* Splits the header on `;`, trims optional whitespace, and matches the cookie
* *name* exactly. A naive `header.find("name=")` substring search would also
* match `xname=`, `my_name=`, `notname=` etc. and latch onto the first hit
* so an attacker who can plant a sibling cookie (subdomain / less-trusted
* same-site host) could shadow the real one, defeating the `__Host-`/
* `__Secure-` prefix guarantees the session cookie may rely on. Pure and
* side-effect-free so the parsing is unit-testable without a request.
*
* @return the cookie value (whitespace-trimmed), or "" if not present.
*/
inline std::string cookieValue(const std::string& cookieHeader, const std::string& name) {
std::size_t i = 0;
const std::size_t n = cookieHeader.size();
while (i < n) {
std::size_t semi = cookieHeader.find(';', i);
std::size_t end = (semi == std::string::npos) ? n : semi;
std::size_t b = i;
while (b < end && (cookieHeader[b] == ' ' || cookieHeader[b] == '\t')) ++b;
std::size_t eq = cookieHeader.find('=', b);
if (eq != std::string::npos && eq < end) {
std::string key = cookieHeader.substr(b, eq - b);
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
if (key == name) {
std::size_t vb = eq + 1, ve = end;
while (vb < ve && (cookieHeader[vb] == ' ' || cookieHeader[vb] == '\t')) ++vb;
while (ve > vb && (cookieHeader[ve - 1] == ' ' || cookieHeader[ve - 1] == '\t')) --ve;
return cookieHeader.substr(vb, ve - vb);
}
}
if (semi == std::string::npos) break;
i = semi + 1;
}
return "";
}
/**
* @brief Pull the session token from an incoming request.
*
@ -19,13 +57,8 @@ using IncomingRequest = oatpp::web::protocol::http::incoming::Request;
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);
}
std::string tok = cookieValue(*cookie, "session");
if (!tok.empty()) return tok;
}
auto auth = request->getHeader("Authorization");
if (auth && !auth->empty()) {
@ -56,6 +89,16 @@ inline bool isValidIp(const std::string& s) {
*
* The `bindAddress` argument carries the host the service is listening on;
* pass your runtime config value here.
*
* @warning Rate-limiting note (authkit#16 M-8): when the service is NOT
* loopback-bound (no trusted ingress proxy), or the proxy omits
* `X-Forwarded-For`/`X-Real-IP`, this returns the constant sentinel
* `"unknown"` (or `"invalid"`) for *every* caller so a per-IP rate
* limiter keyed on it collapses to a single shared bucket and per-IP
* brute-force throttling stops isolating attackers. Deploy
* loopback-bound behind a proxy that sets `X-Forwarded-For`; treat
* `"unknown"`/`"invalid"` as one anonymous bucket and size that
* limit conservatively.
*/
inline std::string clientIpTrusted(
const std::shared_ptr<IncomingRequest>& req,

View file

@ -28,7 +28,7 @@ struct SocketInfo {
std::string userId;
std::string username;
std::string role;
std::set<std::string> propertyIds; ///< Empty = all (admin or no restrictions).
std::set<std::string> propertyIds; ///< Properties this socket may receive scoped events for. Empty = NONE for non-admins (admins get all via role). See socketHasPropertyAccess.
};
/**
@ -45,6 +45,15 @@ struct SocketInfo {
* property-access set so that booking notifications can be scoped to
* authorised recipients.
*
* @warning CSWSH (authkit#16 M-4): a cookie-authenticated WebSocket upgrade is
* exposed to Cross-Site WebSocket Hijacking unless the `Origin` header
* is validated at the handshake. The Hub runs *after* the upgrade and
* cannot see `Origin`, so the WSController MUST reject disallowed
* origins before setting `t_pendingAuth` use
* `oatpp_authkit::sameOrigin(originHeader, hostHeader)` or
* `oatpp_authkit::originAllowed(originHeader, allowlist)` from
* `util/OriginCheck.hpp`.
*
* **Serverclient change notifications**
* @code
* {"type":"booking_updated","id":"<uuid>"}
@ -153,12 +162,16 @@ private:
/**
* @brief Check whether a socket has access to a given property.
*
* Admins and users with no explicit permission rows (empty propertyIds)
* have access to all properties.
* Admins (role == "admin") see everything. For everyone else, access is
* granted only if `propertyId` is explicitly in their `propertyIds` set.
*
* authkit#16 M-3: an empty `propertyIds` now means NO access (fail closed),
* not "all". Previously a non-admin whose permission set failed to populate
* (DB hiccup, race, or simply no grants yet) would receive every property's
* notifications a cross-tenant leak.
*/
static bool socketHasPropertyAccess(const SocketInfo& info, const std::string& propertyId) {
if (info.role == "admin") return true;
if (info.propertyIds.empty()) return true; // no restrictions
return info.propertyIds.find(propertyId) != info.propertyIds.end();
}

View file

@ -10,6 +10,22 @@ add_executable(test_negotiation test_negotiation.cpp)
target_link_libraries(test_negotiation PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME negotiation COMMAND test_negotiation)
add_executable(test_token_extract test_token_extract.cpp)
target_link_libraries(test_token_extract PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME token_extract COMMAND test_token_extract)
add_executable(test_rate_limiter test_rate_limiter.cpp)
target_link_libraries(test_rate_limiter PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME rate_limiter COMMAND test_rate_limiter)
add_executable(test_origin_check test_origin_check.cpp)
target_link_libraries(test_origin_check PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME origin_check COMMAND test_origin_check)
add_executable(test_session_cookie test_session_cookie.cpp)
target_link_libraries(test_session_cookie PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME session_cookie COMMAND test_session_cookie)
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)

View file

@ -0,0 +1,59 @@
// Tests for oatpp-authkit/util/OriginCheck.hpp (authkit#16 M-4 / M-10).
#include "oatpp-authkit/util/OriginCheck.hpp"
#include <cstdio>
#include <string>
#include <vector>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
void test_origin_hostname() {
REQUIRE(originHostname("https://app.example.com") == "app.example.com");
REQUIRE(originHostname("https://app.example.com:8443/x?y=1") == "app.example.com");
REQUIRE(originHostname("app.example.com:443") == "app.example.com");
REQUIRE(originHostname("HTTP://App.Example.COM") == "app.example.com");
REQUIRE(originHostname("example.com") == "example.com");
}
void test_same_origin() {
// Origin host matches Host (port/scheme ignored).
REQUIRE(sameOrigin("https://example.com", "example.com"));
REQUIRE(sameOrigin("https://example.com:8443", "example.com"));
REQUIRE(sameOrigin("https://example.com/page", "example.com")); // Referer form
REQUIRE(sameOrigin("https://example.com", "example.com:443"));
// Cross-host → blocked.
REQUIRE(!sameOrigin("https://evil.com", "example.com"));
REQUIRE(!sameOrigin("https://example.com.evil.com", "example.com"));
// Empty inputs → can't decide → don't block (caller falls back).
REQUIRE(sameOrigin("", "example.com"));
REQUIRE(sameOrigin("https://example.com", ""));
}
void test_origin_allowed() {
std::vector<std::string> allow = {"app.example.com", "https://admin.example.com"};
REQUIRE(originAllowed("https://app.example.com", allow));
REQUIRE(originAllowed("https://admin.example.com:8443/x", allow));
REQUIRE(!originAllowed("https://evil.com", allow));
REQUIRE(!originAllowed("", allow)); // fail closed
}
} // namespace
int main() {
test_origin_hostname();
test_same_origin();
test_origin_allowed();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,61 @@
// Tests for oatpp-authkit/util/RateLimiter.hpp — constructor validation
// (authkit#16 M-7) and basic token-bucket behaviour.
#include "oatpp-authkit/util/RateLimiter.hpp"
#include <cstdio>
#include <stdexcept>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
template <class F>
bool throwsInvalidArg(F&& f) {
try { f(); } catch (const std::invalid_argument&) { return true; } catch (...) { return false; }
return false;
}
void test_ctor_validation() {
REQUIRE(throwsInvalidArg([]{ RateLimiter r(0.0, 1.0); })); // capacity < 1
REQUIRE(throwsInvalidArg([]{ RateLimiter r(-5.0, 1.0); })); // negative capacity
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, 0.0); })); // refill 0 → silent disable
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, -1.0); })); // negative refill
REQUIRE(throwsInvalidArg([]{ RateLimiter r(std::nan(""), 1.0); })); // NaN capacity
REQUIRE(throwsInvalidArg([]{ RateLimiter r(10.0, std::nan("")); })); // NaN refill
REQUIRE(throwsInvalidArg([]{ RateLimiter r(1.0/0.0, 1.0); })); // inf capacity
// Valid construction does not throw.
bool ok = true;
try { RateLimiter r(3.0, 0.5); (void)r; } catch (...) { ok = false; }
REQUIRE(ok);
}
void test_burst_then_deny_and_key_isolation() {
RateLimiter rl(3.0, 0.001); // 3 burst, negligible refill within the test
REQUIRE(rl.allow("ip-a"));
REQUIRE(rl.allow("ip-a"));
REQUIRE(rl.allow("ip-a"));
REQUIRE(!rl.allow("ip-a")); // 4th denied
// Different key has its own independent bucket.
REQUIRE(rl.allow("ip-b"));
}
} // namespace
int main() {
test_ctor_validation();
test_burst_then_deny_and_key_isolation();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -156,6 +156,32 @@ void test_null_valid_until_treated_as_live() {
REQUIRE(got->tlsCertDn);
}
// authkit#16 M-6: a redaction field name that doesn't exist on the DTO must
// throw at construction — a silent no-op would leave credentials in history.
void test_unknown_field_throws() {
auto inner = std::make_shared<FakeInner>();
bool threw = false;
try {
RedactedFieldRepository<CredDto> bad(inner, {"passwordHash", "passowrdHash" /* typo */});
} catch (const std::invalid_argument&) {
threw = true;
}
REQUIRE(threw);
// Wrong casing / JSON-name instead of C++ identifier also throws.
bool threw2 = false;
try {
RedactedFieldRepository<CredDto> bad2(inner, {"password_hash" /* JSON name, not the DTO_FIELD id */});
} catch (const std::invalid_argument&) {
threw2 = true;
}
REQUIRE(threw2);
// A correct set constructs fine.
RedactedFieldRepository<CredDto> ok(inner, {"passwordHash", "tlsCertDn"});
(void)ok;
}
} // namespace
int main() {
@ -164,6 +190,7 @@ int main() {
test_partial_redaction_list();
test_empty_redaction_list_passes_everything_through();
test_null_valid_until_treated_as_live();
test_unknown_field_throws();
std::printf("test_redacted_field_repository: OK\n");
return 0;
}

View file

@ -0,0 +1,75 @@
// Tests for oatpp-authkit/util/SessionCookie.hpp (authkit#16 M-9).
#include "oatpp-authkit/util/SessionCookie.hpp"
#include <cstdio>
#include <stdexcept>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
bool has(const std::string& hay, const std::string& needle) {
return hay.find(needle) != std::string::npos;
}
void test_defaults_are_hardened() {
std::string c = buildSetSessionCookie("tok123");
REQUIRE(has(c, "session=tok123"));
REQUIRE(has(c, "Path=/"));
REQUIRE(has(c, "HttpOnly"));
REQUIRE(has(c, "Secure"));
REQUIRE(has(c, "SameSite=Strict"));
REQUIRE(!has(c, "Max-Age")); // session cookie by default
}
void test_options_respected() {
SessionCookieOptions o;
o.name = "__Host-session";
o.secure = false; // dev opt-out
o.sameSite = "Lax";
o.maxAgeSeconds = 3600;
std::string c = buildSetSessionCookie("t", o);
REQUIRE(has(c, "__Host-session=t"));
REQUIRE(!has(c, "Secure"));
REQUIRE(has(c, "SameSite=Lax"));
REQUIRE(has(c, "Max-Age=3600"));
}
void test_clear_cookie_expires_now() {
std::string c = buildClearSessionCookie();
REQUIRE(has(c, "Max-Age=0"));
REQUIRE(has(c, "session="));
}
void test_injection_guard() {
bool threw = false;
try { buildSetSessionCookie("tok\r\nSet-Cookie: evil=1"); }
catch (const std::invalid_argument&) { threw = true; }
REQUIRE(threw);
bool threw2 = false;
try { buildSetSessionCookie("tok; Domain=evil.com"); } // ';' injection
catch (const std::invalid_argument&) { threw2 = true; }
REQUIRE(threw2);
}
} // namespace
int main() {
test_defaults_are_hardened();
test_options_respected();
test_clear_cookie_expires_now();
test_injection_guard();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}

View file

@ -0,0 +1,67 @@
// Tests for oatpp-authkit/util/TokenExtract.hpp — exact-name cookie parsing
// (authkit#16 M-1) and isValidIp.
#include "oatpp-authkit/util/TokenExtract.hpp"
#include <cstdio>
#include <string>
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
using namespace oatpp_authkit;
void test_cookie_exact_name_match() {
// Basic.
REQUIRE(cookieValue("session=abc", "session") == "abc");
REQUIRE(cookieValue("session=abc; other=1", "session") == "abc");
REQUIRE(cookieValue("other=1; session=abc", "session") == "abc");
REQUIRE(cookieValue("other=1; session=abc; more=2", "session") == "abc");
// OWS trimming around the pair and value.
REQUIRE(cookieValue("a=1; session=abc ; b=2", "session") == "abc");
// The substring trap: a prefixed/suffixed cookie name must NOT match.
REQUIRE(cookieValue("xsession=evil", "session") == "");
REQUIRE(cookieValue("notsession=evil", "session") == "");
REQUIRE(cookieValue("my_session=evil", "session") == "");
// Attacker plants a sibling cookie before the real one: exact match still
// returns the genuine session value, not the shadow.
REQUIRE(cookieValue("xsession=evil; session=real", "session") == "real");
REQUIRE(cookieValue("session=real; xsession=evil", "session") == "real");
// Missing / empty.
REQUIRE(cookieValue("", "session") == "");
REQUIRE(cookieValue("foo=bar", "session") == "");
REQUIRE(cookieValue("session=", "session") == "");
// __Host- prefixed name is matched only as an exact name.
REQUIRE(cookieValue("__Host-session=tok", "__Host-session") == "tok");
REQUIRE(cookieValue("__Host-session=tok", "session") == "");
}
void test_is_valid_ip() {
REQUIRE(isValidIp("192.168.1.1"));
REQUIRE(isValidIp("::1"));
REQUIRE(isValidIp("2001:db8::1"));
REQUIRE(!isValidIp("192.168.1.256"));
REQUIRE(!isValidIp("1.1.1.1; rm -rf"));
REQUIRE(!isValidIp(""));
REQUIRE(!isValidIp(std::string(46, 'a'))); // over length cap
}
} // namespace
int main() {
test_cookie_exact_name_match();
test_is_valid_ip();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}