diff --git a/README.md b/README.md index d932b3b..967e5d4 100644 --- a/README.md +++ b/README.md @@ -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` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `TemporalFieldTraits` 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` 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`. 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::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`. Wrap a scope-guarded `IQueryable` with `ScopeGuardQueryable` (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::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract::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 diff --git a/include/oatpp-authkit/auth/AuthInterceptor.hpp b/include/oatpp-authkit/auth/AuthInterceptor.hpp index 052f933..71e1478 100644 --- a/include/oatpp-authkit/auth/AuthInterceptor.hpp +++ b/include/oatpp-authkit/auth/AuthInterceptor.hpp @@ -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; * * 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 m_runtime; TokenHasher m_hashToken; std::shared_ptr m_mapper; + std::shared_ptr 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(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 policy, std::shared_ptr runtime, TokenHasher hashToken, - std::shared_ptr mapper = nullptr) + std::shared_ptr mapper = nullptr, + std::shared_ptr 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 intercept( const std::shared_ptr& 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); diff --git a/include/oatpp-authkit/auth/IAuthBackend.hpp b/include/oatpp-authkit/auth/IAuthBackend.hpp index 71c86d4..4717994 100644 --- a/include/oatpp-authkit/auth/IAuthBackend.hpp +++ b/include/oatpp-authkit/auth/IAuthBackend.hpp @@ -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. */ diff --git a/include/oatpp-authkit/repo/RedactedFieldRepository.hpp b/include/oatpp-authkit/repo/RedactedFieldRepository.hpp index eca71d5..52103c4 100644 --- a/include/oatpp-authkit/repo/RedactedFieldRepository.hpp +++ b/include/oatpp-authkit/repo/RedactedFieldRepository.hpp @@ -32,6 +32,7 @@ #include "oatpp/core/Types.hpp" #include +#include #include #include #include @@ -66,7 +67,27 @@ public: RedactedFieldRepository(std::shared_ptr> inner, std::vector 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::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 findByEntityId(const oatpp::String& entityId) override { return m_inner->findByEntityId(entityId); diff --git a/include/oatpp-authkit/util/OriginCheck.hpp b/include/oatpp-authkit/util/OriginCheck.hpp new file mode 100644 index 0000000..cee8884 --- /dev/null +++ b/include/oatpp-authkit/util/OriginCheck.hpp @@ -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 +#include +#include +#include + +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(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& 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 diff --git a/include/oatpp-authkit/util/RateLimiter.hpp b/include/oatpp-authkit/util/RateLimiter.hpp index 4a8bad4..2d813a3 100644 --- a/include/oatpp-authkit/util/RateLimiter.hpp +++ b/include/oatpp-authkit/util/RateLimiter.hpp @@ -2,7 +2,9 @@ #define UTIL_RATE_LIMITER_HPP #include +#include #include +#include #include #include @@ -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) { diff --git a/include/oatpp-authkit/util/SessionCookie.hpp b/include/oatpp-authkit/util/SessionCookie.hpp new file mode 100644 index 0000000..f37d540 --- /dev/null +++ b/include/oatpp-authkit/util/SessionCookie.hpp @@ -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 +#include + +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 diff --git a/include/oatpp-authkit/util/TokenExtract.hpp b/include/oatpp-authkit/util/TokenExtract.hpp index 50a4ca7..6991701 100644 --- a/include/oatpp-authkit/util/TokenExtract.hpp +++ b/include/oatpp-authkit/util/TokenExtract.hpp @@ -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& 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& req, diff --git a/include/oatpp-authkit/ws/Hub.hpp b/include/oatpp-authkit/ws/Hub.hpp index ddfd49c..f6b0788 100644 --- a/include/oatpp-authkit/ws/Hub.hpp +++ b/include/oatpp-authkit/ws/Hub.hpp @@ -28,7 +28,7 @@ struct SocketInfo { std::string userId; std::string username; std::string role; - std::set propertyIds; ///< Empty = all (admin or no restrictions). + std::set 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`. + * * **Server→client change notifications** * @code * {"type":"booking_updated","id":""} @@ -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(); } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9d439e6..017762d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) diff --git a/test/test_origin_check.cpp b/test/test_origin_check.cpp new file mode 100644 index 0000000..8b6b538 --- /dev/null +++ b/test/test_origin_check.cpp @@ -0,0 +1,59 @@ +// Tests for oatpp-authkit/util/OriginCheck.hpp (authkit#16 M-4 / M-10). + +#include "oatpp-authkit/util/OriginCheck.hpp" + +#include +#include +#include + +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 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; +} diff --git a/test/test_rate_limiter.cpp b/test/test_rate_limiter.cpp new file mode 100644 index 0000000..ca947fe --- /dev/null +++ b/test/test_rate_limiter.cpp @@ -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 +#include +#include + +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 +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; +} diff --git a/test/test_redacted_field_repository.cpp b/test/test_redacted_field_repository.cpp index 2a60446..b668a2f 100644 --- a/test/test_redacted_field_repository.cpp +++ b/test/test_redacted_field_repository.cpp @@ -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(); + bool threw = false; + try { + RedactedFieldRepository 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 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 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; } diff --git a/test/test_session_cookie.cpp b/test/test_session_cookie.cpp new file mode 100644 index 0000000..b3f71fe --- /dev/null +++ b/test/test_session_cookie.cpp @@ -0,0 +1,75 @@ +// Tests for oatpp-authkit/util/SessionCookie.hpp (authkit#16 M-9). + +#include "oatpp-authkit/util/SessionCookie.hpp" + +#include +#include +#include + +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; +} diff --git a/test/test_token_extract.cpp b/test/test_token_extract.cpp new file mode 100644 index 0000000..10b90a4 --- /dev/null +++ b/test/test_token_extract.cpp @@ -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 +#include + +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; +}