Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9976efe1de | |||
| fafee1278f | |||
| 2e11408240 |
28 changed files with 1280 additions and 72 deletions
14
README.md
14
README.md
|
|
@ -10,16 +10,20 @@ 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. |
|
||||
| `util/ConstantTime.hpp` | `constantTimeEquals(a, b)` — branch-free secret comparison for consumers that compare a token/HMAC/hash in memory rather than via an indexed store lookup (authkit#16 L-7). |
|
||||
| `mail/SmtpTransport.hpp` | libcurl SMTP+MIME sender. Requires TLS (`CURLUSESSL_ALL`) for non-loopback relays so credentials/body can't be sent cleartext if STARTTLS is stripped (authkit#16 L-2); a `localhost`/`127.0.0.1` relay stays opportunistic. Rejects CR/LF/NUL in `to`/`fromAddress` (header-injection guard, authkit#16 H-5). |
|
||||
| `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)`. |
|
||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
|
||||
| `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>`. |
|
||||
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate, an actor accessor, and an `entity_id` accessor at construction; gates every method on the predicate. On `save` the predicate must pass on the incoming DTO **and**, for an update, on the row as it currently stands — so an actor can't reparent an out-of-scope row into its own scope by relabelling it in the request body. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. **`ScopeGuardQueryable<T>`** (same header) is the variant for `IQueryable` inners: it filters `query()` results through the predicate too, so the queryable surface can't bypass the guard. |
|
||||
| `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. For user-supplied search terms use `likeContains`/`likePrefix` (or `likeEscape`), which escape `%`/`_`/`\` and emit `LIKE ? ESCAPE '\'` (authkit#16 L-8); raw `like()` binds the pattern verbatim (trusted patterns only). Column/table identifiers come only from compile-time registration — never from request data. 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
#ifndef OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
#define OATPP_AUTHKIT_AUTH_INTERCEPTOR_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
|
|
@ -16,11 +18,19 @@
|
|||
#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 {
|
||||
|
||||
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically. */
|
||||
/** @brief Caller-supplied hash function — SHA-256 on the raw token typically.
|
||||
*
|
||||
* authkit#16 L-7: MUST be a fixed-length cryptographic hash (≥256-bit, e.g.
|
||||
* SHA-256) over a high-entropy token. The store looks the session/API key up
|
||||
* by this hash, so a weak/short/truncating hash weakens matching. Consumers
|
||||
* that compare a secret in memory (rather than via an indexed lookup) should
|
||||
* use `oatpp_authkit::constantTimeEquals` (`util/ConstantTime.hpp`). */
|
||||
using TokenHasher = std::function<std::string(const std::string&)>;
|
||||
|
||||
/**
|
||||
|
|
@ -28,10 +38,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 +58,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 +188,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,24 +221,38 @@ 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
|
||||
{
|
||||
// Periodic expired-session sweep — at most once per hour.
|
||||
// Periodic expired-session GC — at most once per hour, process-wide.
|
||||
// authkit#16 L-6: this is best-effort cleanup, NOT the expiry gate —
|
||||
// resolveBySessionHash() must itself reject expired sessions (see
|
||||
// IAuthBackend). The timer is a lock-free atomic so concurrent requests
|
||||
// don't race the read-modify-write, and the sweep is exception-isolated
|
||||
// so a transient DB error during GC can't 500 an otherwise-valid request.
|
||||
{
|
||||
using Clock = std::chrono::steady_clock;
|
||||
static Clock::time_point lastCleanup = Clock::now();
|
||||
auto now = Clock::now();
|
||||
if (std::chrono::duration_cast<std::chrono::hours>(now - lastCleanup).count() >= 1) {
|
||||
lastCleanup = now;
|
||||
m_backend->deleteExpiredSessions();
|
||||
static std::atomic<std::int64_t> lastCleanupMs{-1};
|
||||
const std::int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
Clock::now().time_since_epoch()).count();
|
||||
std::int64_t prev = lastCleanupMs.load(std::memory_order_relaxed);
|
||||
if (prev < 0) {
|
||||
// First request: arm the timer, don't sweep yet.
|
||||
lastCleanupMs.compare_exchange_strong(prev, nowMs);
|
||||
} else if (nowMs - prev >= 3600000) {
|
||||
// Only the thread that wins the CAS performs the sweep.
|
||||
if (lastCleanupMs.compare_exchange_strong(prev, nowMs)) {
|
||||
try { m_backend->deleteExpiredSessions(); } catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,16 +265,30 @@ 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;
|
||||
}
|
||||
|
||||
// TLS cert DN — only trusted when the runtime hook says so (#5).
|
||||
// `certAuthTrusted()` defaults to `isLoopback()`; consumers can override
|
||||
// it to gate more strictly (e.g. require an env-var or a TLS-only port).
|
||||
// `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))) {
|
||||
|
|
@ -260,6 +316,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);
|
||||
}
|
||||
|
|
@ -271,6 +337,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);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ namespace oatpp_authkit {
|
|||
* into this struct inside their IAuthBackend implementation.
|
||||
*/
|
||||
struct AuthPrincipal {
|
||||
int id{0}; ///< Stable numeric id from the user store.
|
||||
/// Stable numeric id from the user store. NOTE (authkit#16 L-1): this is an
|
||||
/// `int`, so it only round-trips numeric ids. A store keyed on UUIDs / other
|
||||
/// non-numeric ids must not stuff them here — `requireUser` rejects a
|
||||
/// non-numeric bundle id with 401. Carry such identities in `username` (or
|
||||
/// extend this struct) instead.
|
||||
int id{0};
|
||||
std::string username;
|
||||
std::string role; ///< Arbitrary string; policy decides what "admin"/"readonly" mean.
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,14 @@ class IAuthBackend {
|
|||
public:
|
||||
virtual ~IAuthBackend() = default;
|
||||
|
||||
/** @brief Look up an active session by its hashed token. */
|
||||
/** @brief Look up an *active, non-expired* session by its hashed token.
|
||||
*
|
||||
* @warning Enforce expiry HERE (authkit#16 L-6): filter on the session's
|
||||
* `expires_at` in this query and return `std::nullopt` for an
|
||||
* expired row. The interceptor's periodic `deleteExpiredSessions`
|
||||
* is best-effort garbage collection that only runs on request
|
||||
* traffic — relying on it for expiry would leave a stale token
|
||||
* valid until the next sweep (or indefinitely on an idle server). */
|
||||
virtual std::optional<AuthPrincipal> resolveBySessionHash(const std::string& hash) = 0;
|
||||
|
||||
/** @brief Look up an API key by its hashed token; also touch `last_used_at`. */
|
||||
|
|
@ -38,7 +45,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. */
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ public:
|
|||
|
||||
/** @brief Whether incoming `X-SSL-Client-DN` headers should be trusted (#5).
|
||||
*
|
||||
* Default: `isLoopback()` — preserves the legacy behaviour for consumers
|
||||
* that haven't overridden anything. Override to gate more strictly, e.g.:
|
||||
* Default: `false` — **fail closed**. `X-SSL-Client-DN` is an ordinary
|
||||
* request header; binding to loopback does NOT guarantee it originates
|
||||
* from a TLS-terminating proxy. An SSH tunnel, a co-located process, or a
|
||||
* reverse proxy that forwards the client-supplied header verbatim can all
|
||||
* present an arbitrary DN to a loopback-bound service, so trusting it by
|
||||
* default is an authentication-bypass primitive. Consumers must opt in
|
||||
* explicitly, and only once the upstream proxy unconditionally strips the
|
||||
* inbound header and re-sets it from a verified client certificate, e.g.:
|
||||
*
|
||||
* bool certAuthTrusted() override {
|
||||
* return isLoopback() && std::getenv("TRUST_CERT_DN") != nullptr;
|
||||
|
|
@ -45,7 +51,7 @@ public:
|
|||
* `X-SSL-Client-DN` header and falls through to token / session auth.
|
||||
*/
|
||||
virtual bool certAuthTrusted() {
|
||||
return isLoopback();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,22 @@ inline AuthPrincipal requireUser(const std::shared_ptr<IncomingRequest>& request
|
|||
OATPP_ASSERT_HTTP(id && role, Status::CODE_401, "Authentication required");
|
||||
|
||||
AuthPrincipal p;
|
||||
p.id = std::stoi(std::string(*id));
|
||||
// authkit#16 L-1: parse defensively. The bundle id is normally a decimal
|
||||
// written by AuthInterceptor, but a non-numeric / out-of-range value (or a
|
||||
// future principal id that isn't an int) must surface as a clean 401, not
|
||||
// an uncaught std::invalid_argument/out_of_range escaping the endpoint as a
|
||||
// 500. The OATPP_ASSERT_HTTP is kept OUTSIDE the try so its HttpError isn't
|
||||
// swallowed by the catch.
|
||||
bool idOk = false;
|
||||
{
|
||||
const std::string idStr(*id);
|
||||
try {
|
||||
std::size_t consumed = 0;
|
||||
int parsed = std::stoi(idStr, &consumed);
|
||||
if (consumed == idStr.size()) { p.id = parsed; idOk = true; }
|
||||
} catch (...) { idOk = false; }
|
||||
}
|
||||
OATPP_ASSERT_HTTP(idOk, Status::CODE_401, "Authentication required");
|
||||
p.role = std::string(*role);
|
||||
p.username = username ? std::string(*username) : "";
|
||||
return p;
|
||||
|
|
|
|||
|
|
@ -81,18 +81,44 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_table_entity ON audit_log(table_name
|
|||
private:
|
||||
std::shared_ptr<AuditLogDb> m_db;
|
||||
|
||||
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata. */
|
||||
/** @brief Fields to skip when computing UPDATE diffs — internal/metadata
|
||||
* plus credentials. authkit#16 L-3: never copy a secret into the long-lived
|
||||
* `audit_log.changed_fields` column (covers both snake_case and camelCase
|
||||
* identifiers since the diff matches on the DTO's C++ field name). */
|
||||
static inline const std::set<std::string> SKIP_FIELDS = {
|
||||
"id", "entity_id", "created_at", "updated_at", "valid_from"
|
||||
"id", "entity_id", "created_at", "updated_at", "valid_from",
|
||||
"password", "passwordHash", "password_hash",
|
||||
"tlsCertDn", "tls_cert_dn",
|
||||
"apiKey", "api_key", "token", "secret"
|
||||
};
|
||||
|
||||
/** @brief RFC 8259-compliant JSON string escaping. authkit#16 L-3: the
|
||||
* previous version escaped only `\` and `"`, so a control character (e.g.
|
||||
* a newline in a user-supplied name) produced invalid JSON in the audit
|
||||
* trail and allowed newline/log injection into anything re-emitting the
|
||||
* column. */
|
||||
static std::string escapeJson(const std::string& s) {
|
||||
static const char* hex = "0123456789abcdef";
|
||||
std::string out;
|
||||
out.reserve(s.size());
|
||||
for (char c : s) {
|
||||
if (c == '\\') out += "\\\\";
|
||||
else if (c == '"') out += "\\\"";
|
||||
else out += c;
|
||||
out.reserve(s.size() + 8);
|
||||
for (unsigned char c : s) {
|
||||
switch (c) {
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '"': out += "\\\""; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
out += "\\u00";
|
||||
out += hex[(c >> 4) & 0xF];
|
||||
out += hex[c & 0xF];
|
||||
} else {
|
||||
out += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ struct SmtpConfig {
|
|||
std::string password;
|
||||
};
|
||||
|
||||
/** @brief True if `s` contains CR, LF or NUL — characters that would let a
|
||||
* caller-influenced address smuggle extra SMTP/MIME headers (BCC injection,
|
||||
* added recipients, body injection) when concatenated into a header line. */
|
||||
inline bool hasHeaderInjectionChars(const std::string& s) {
|
||||
return s.find('\r') != std::string::npos
|
||||
|| s.find('\n') != std::string::npos
|
||||
|| s.find('\0') != std::string::npos;
|
||||
}
|
||||
|
||||
/** @brief RFC 4648 Base64 encode — used for RFC 2047 Subject headers. */
|
||||
inline std::string base64Encode(const std::string& data) {
|
||||
static const char* table =
|
||||
|
|
@ -76,6 +85,13 @@ inline std::string send(
|
|||
if (cfg.host.empty()) return "SMTP not configured (no host)";
|
||||
if (cfg.fromAddress.empty()) return "SMTP not configured (no from_address)";
|
||||
|
||||
// Reject control characters in the addresses before they reach the envelope
|
||||
// (MAIL FROM / RCPT TO) and the From:/To: header lines. The subject is safe
|
||||
// — it is RFC 2047 base64 encoded-word wrapped below — but the addresses are
|
||||
// concatenated raw, so a `\r\n` here would inject arbitrary headers.
|
||||
if (hasHeaderInjectionChars(to)) return "invalid recipient address (control characters)";
|
||||
if (hasHeaderInjectionChars(cfg.fromAddress)) return "invalid from address (control characters)";
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) return "curl_easy_init failed";
|
||||
|
||||
|
|
@ -91,9 +107,14 @@ inline std::string send(
|
|||
curl_easy_setopt(curl, CURLOPT_USERNAME, cfg.username.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_PASSWORD, cfg.password.c_str());
|
||||
}
|
||||
curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_TRY);
|
||||
// Allow self-signed certs on localhost relay — a common dev / pipe-transport setup.
|
||||
if (cfg.host == "localhost" || cfg.host == "127.0.0.1") {
|
||||
// authkit#16 L-2: require TLS for non-loopback relays. CURLUSESSL_TRY would
|
||||
// silently fall back to cleartext if STARTTLS is unavailable or stripped by
|
||||
// a MITM, leaking the SMTP AUTH credentials and message body. A local relay
|
||||
// (localhost / pipe transport) stays on TRY with verification relaxed since
|
||||
// there's no network hop to protect.
|
||||
const bool loopbackRelay = (cfg.host == "localhost" || cfg.host == "127.0.0.1");
|
||||
curl_easy_setopt(curl, CURLOPT_USE_SSL, loopbackRelay ? CURLUSESSL_TRY : CURLUSESSL_ALL);
|
||||
if (loopbackRelay) {
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ namespace oatpp_authkit::repo {
|
|||
// two function templates. The primary templates are intentionally declared
|
||||
// without a definition: forgetting to register a field is a hard compile or
|
||||
// link error rather than a runtime surprise.
|
||||
//
|
||||
// SECURITY INVARIANT (authkit#16 L-8): column and table *identifiers* are
|
||||
// emitted into SQL unparameterised (SQL placeholders can't bind identifiers).
|
||||
// They come ONLY from these compile-time registrations / `Field<&Dto::mem>`,
|
||||
// never from request data — so there is no injection vector. Never construct
|
||||
// an `OrderBySpec`/`Field` column name from a runtime/user string; map a
|
||||
// client sort field to a registered `Field` via an allowlist first. All
|
||||
// *values* (eq/ne/in/like/...) are always bound as `?` parameters.
|
||||
|
||||
template <auto MemPtr>
|
||||
const char* columnName();
|
||||
|
|
@ -69,6 +77,22 @@ inline BindValue toBindValue(const oatpp::String& v) {
|
|||
return v ? BindValue{std::string(*v)} : BindValue{};
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Escape LIKE wildcards (`%`, `_`) and the escape char (`\`) in a
|
||||
* user-supplied search term so they're matched literally (authkit#16
|
||||
* L-8). Pair with the `LIKE ? ESCAPE '\'` clause emitted by
|
||||
* `Field::likeContains` / `Field::likePrefix`.
|
||||
*/
|
||||
inline std::string likeEscape(const std::string& term) {
|
||||
std::string out;
|
||||
out.reserve(term.size() + 4);
|
||||
for (char c : term) {
|
||||
if (c == '\\' || c == '%' || c == '_') out.push_back('\\');
|
||||
out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── AST nodes ──────────────────────────────────────────────────────────────
|
||||
|
||||
class AstNode {
|
||||
|
|
@ -122,6 +146,20 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
/** @brief `col LIKE ? ESCAPE '\'` — the explicit ESCAPE makes a `\`-escaped
|
||||
* pattern (see `likeEscape`) treat `%`/`_` literally. */
|
||||
class LikeNode : public AstNode {
|
||||
std::string col_;
|
||||
BindValue val_;
|
||||
public:
|
||||
LikeNode(std::string c, BindValue v) : col_(std::move(c)), val_(std::move(v)) {}
|
||||
void emit(std::ostringstream& sql,
|
||||
std::vector<BindValue>& binds) const override {
|
||||
sql << col_ << " LIKE ? ESCAPE '\\'";
|
||||
binds.push_back(val_);
|
||||
}
|
||||
};
|
||||
|
||||
class CombineNode : public AstNode {
|
||||
const char* sep_;
|
||||
std::vector<std::shared_ptr<AstNode>> children_;
|
||||
|
|
@ -218,10 +256,28 @@ public:
|
|||
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
|
||||
}
|
||||
|
||||
/** @brief Raw `col LIKE ?` with the pattern bound verbatim. The caller owns
|
||||
* the `%`/`_` wildcards — only pass a TRUSTED pattern here. For a
|
||||
* user-supplied search term use `likeContains` / `likePrefix` (which
|
||||
* escape the metacharacters), or wrap it with `likeEscape`. */
|
||||
Predicate like(const std::string& pat) const {
|
||||
return Predicate{std::make_shared<CompareNode>(
|
||||
column(), "LIKE", BindValue{pat})};
|
||||
}
|
||||
|
||||
/** @brief Substring match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '%<escaped>%' ESCAPE '\'` (authkit#16 L-8). */
|
||||
Predicate likeContains(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{"%" + likeEscape(term) + "%"})};
|
||||
}
|
||||
|
||||
/** @brief Prefix match of a user-supplied `term` with LIKE wildcards
|
||||
* escaped — emits `col LIKE '<escaped>%' ESCAPE '\'`. */
|
||||
Predicate likePrefix(const std::string& term) const {
|
||||
return Predicate{std::make_shared<LikeNode>(
|
||||
column(), BindValue{likeEscape(term) + "%"})};
|
||||
}
|
||||
Predicate isNull() const { return Predicate{std::make_shared<IsNullNode>(column(), true)}; }
|
||||
Predicate isNotNull() const { return Predicate{std::make_shared<IsNullNode>(column(), false)}; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/SchemaContract.hpp"
|
||||
#include "oatpp-authkit/repo/IQueryable.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -40,19 +41,33 @@ public:
|
|||
* error tradeoff: throwing is the safer default — callers that want to
|
||||
* silently 404 instead can catch and translate.)
|
||||
* - `list()`: load from inner; filter out rows the predicate denies.
|
||||
* - `save(dto)`: predicate evaluated on the incoming dto; deny ⇒ throw.
|
||||
* - `save(dto)`: predicate must pass on the incoming dto AND, for an update,
|
||||
* on the row as it currently stands. Checking only the incoming dto would
|
||||
* let an actor reparent an out-of-scope row into its own scope by setting
|
||||
* the scope field in the request body (BOLA / set-your-own-scope). The
|
||||
* existing-row lookup uses the constructor-injected `entityIdOf` accessor.
|
||||
* - `softDelete(id)`: load from inner; if denied, throw; otherwise delegate.
|
||||
*
|
||||
* The actor is provided via a constructor-injected accessor so a single
|
||||
* `ScopeGuardRepository` instance can serve many requests with different
|
||||
* actors (typically the accessor reads from the per-request authenticated
|
||||
* principal — fewo-webapp's `AuthInterceptor` populates one).
|
||||
*
|
||||
* @note `IQueryable<TDto>` is a *separate* data-access surface. Wrapping an
|
||||
* `IQueryable` repo in this decorator does NOT guard `query()` — a
|
||||
* caller that obtains the inner queryable would bypass the scope
|
||||
* predicate entirely. Use `ScopeGuardQueryable<TDto>` (below) when the
|
||||
* inner exposes the queryable capability.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardRepository : public Repository<TDto> {
|
||||
public:
|
||||
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
||||
using ActorAccess = std::function<ActorContext()>;
|
||||
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
||||
using ActorAccess = std::function<ActorContext()>;
|
||||
/// Extracts the stable `entity_id` from a DTO (e.g. `[](auto& d){ return d->entity_id; }`).
|
||||
/// Used to load the existing row on `save()` so an update can't reparent an
|
||||
/// out-of-scope row. Returns null for a not-yet-allocated entity (fresh insert).
|
||||
using EntityIdAccess = std::function<oatpp::String(const oatpp::Object<TDto>&)>;
|
||||
|
||||
/// Declarative schema contribution (authkit#14, D-replace).
|
||||
/// ScopeGuard touches no schema — empty contributions exposed so it
|
||||
|
|
@ -66,10 +81,12 @@ public:
|
|||
|
||||
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor)
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(std::move(isAllowed))
|
||||
, m_currentActor(std::move(currentActor))
|
||||
, m_entityIdOf(std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
|
|
@ -92,8 +109,25 @@ public:
|
|||
}
|
||||
|
||||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (!m_isAllowed(m_currentActor(), dto)) {
|
||||
throw ScopeDeniedException("scope guard denied save");
|
||||
const ActorContext actor = m_currentActor();
|
||||
// 1. The incoming DTO must be in scope — you can't write into a scope
|
||||
// you don't own.
|
||||
if (!m_isAllowed(actor, dto)) {
|
||||
throw ScopeDeniedException("scope guard denied save (incoming)");
|
||||
}
|
||||
// 2. If this is an update of an existing entity, the row as it stands
|
||||
// NOW must also be in scope. Otherwise an actor scoped to A could
|
||||
// take an entity currently owned by B and reparent it into A simply
|
||||
// by setting the scope field in the body. A fresh insert has a null
|
||||
// entity_id (or no matching row) and skips this check.
|
||||
if (m_entityIdOf) {
|
||||
auto eid = m_entityIdOf(dto);
|
||||
if (eid) {
|
||||
auto existing = m_inner->findByEntityId(eid);
|
||||
if (existing && !m_isAllowed(actor, existing)) {
|
||||
throw ScopeDeniedException("scope guard denied save (existing row out of scope)");
|
||||
}
|
||||
}
|
||||
}
|
||||
m_inner->save(dto);
|
||||
}
|
||||
|
|
@ -111,6 +145,64 @@ private:
|
|||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
EntityIdAccess m_entityIdOf;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief `ScopeGuardRepository` for inners that also expose `IQueryable<TDto>`.
|
||||
*
|
||||
* `ScopeGuardRepository` guards only the four `Repository<TDto>` methods; the
|
||||
* `IQueryable::query()` surface is separate, so a scope-guarded `IQueryable`
|
||||
* repo would otherwise leak every row a raw query returns. This decorator
|
||||
* closes that hole: it implements `IQueryable<TDto>`, delegates the CRUD
|
||||
* methods to an embedded `ScopeGuardRepository` (same predicate / actor /
|
||||
* entity-id semantics, including the reparenting check), and post-filters
|
||||
* `query()` results through the predicate exactly like `list()` does.
|
||||
*
|
||||
* Wire this — not the plain `ScopeGuardRepository` — whenever the concrete
|
||||
* inner derives from `IQueryable<TDto>`.
|
||||
*/
|
||||
template <class TDto>
|
||||
class ScopeGuardQueryable : public IQueryable<TDto> {
|
||||
public:
|
||||
using Predicate = typename ScopeGuardRepository<TDto>::Predicate;
|
||||
using ActorAccess = typename ScopeGuardRepository<TDto>::ActorAccess;
|
||||
using EntityIdAccess = typename ScopeGuardRepository<TDto>::EntityIdAccess;
|
||||
|
||||
ScopeGuardQueryable(std::shared_ptr<IQueryable<TDto>> inner,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor,
|
||||
EntityIdAccess entityIdOf)
|
||||
: m_inner(std::move(inner))
|
||||
, m_isAllowed(isAllowed)
|
||||
, m_currentActor(currentActor)
|
||||
, m_guard(m_inner, std::move(isAllowed), std::move(currentActor), std::move(entityIdOf))
|
||||
{}
|
||||
|
||||
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
|
||||
return m_guard.findByEntityId(entityId);
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<TDto>> list() override { return m_guard.list(); }
|
||||
void save(const oatpp::Object<TDto>& dto) override { m_guard.save(dto); }
|
||||
void softDelete(const oatpp::String& entityId) override { m_guard.softDelete(entityId); }
|
||||
|
||||
/** @brief Run the inner query, then drop every row the predicate denies. */
|
||||
oatpp::Vector<oatpp::Object<TDto>> query(const Query<TDto>& q) override {
|
||||
auto rows = m_inner->query(q);
|
||||
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
|
||||
if (!rows) return out;
|
||||
const ActorContext actor = m_currentActor();
|
||||
for (auto& row : *rows) {
|
||||
if (m_isAllowed(actor, row)) out->push_back(row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<IQueryable<TDto>> m_inner;
|
||||
Predicate m_isAllowed;
|
||||
ActorAccess m_currentActor;
|
||||
ScopeGuardRepository<TDto> m_guard;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
|
@ -109,18 +110,31 @@ public:
|
|||
|
||||
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
|
||||
using IdGen = std::function<oatpp::String()>;
|
||||
/// Runs a unit of work, ideally inside a DB transaction so the historical
|
||||
/// insert + live update commit or roll back together. The default just
|
||||
/// invokes the callback inline (no cross-statement atomicity); consumers
|
||||
/// that have a connection/transaction handle should pass a runner that
|
||||
/// wraps the callback in `BEGIN … COMMIT` / `ROLLBACK`.
|
||||
using TxRunner = std::function<void(const std::function<void()>&)>;
|
||||
|
||||
/**
|
||||
* @param inner Concrete adapter that exposes all-rows-including-historical.
|
||||
* @param clock Optional injected clock for tests; default uses system_clock.
|
||||
* @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64.
|
||||
* @param txRunner Optional transaction wrapper for the close-then-insert
|
||||
* write pair; default runs the writes inline. A per-instance mutex
|
||||
* already serialises the read-modify-write within this process so
|
||||
* concurrent saves of the same entity can't produce two live rows;
|
||||
* supply a real transaction runner for crash/rollback atomicity.
|
||||
*/
|
||||
explicit TemporalRepository(std::shared_ptr<Repository<TDto>> inner,
|
||||
Clock clock = {},
|
||||
IdGen idgen = {})
|
||||
IdGen idgen = {},
|
||||
TxRunner txRunner = {})
|
||||
: m_inner(std::move(inner))
|
||||
, m_clock(clock ? std::move(clock) : defaultClock())
|
||||
, m_idgen(idgen ? std::move(idgen) : defaultIdGen())
|
||||
, m_runTx(txRunner ? std::move(txRunner) : defaultTxRunner())
|
||||
{}
|
||||
|
||||
using F = TemporalFieldTraits<TDto>;
|
||||
|
|
@ -180,6 +194,12 @@ public:
|
|||
void save(const oatpp::Object<TDto>& dto) override {
|
||||
if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
|
||||
|
||||
// Serialise the read-modify-write so two concurrent saves of the same
|
||||
// entity can't both observe the same live row and each insert a new
|
||||
// SENTINEL row (lost update / two live rows). In-process guard only;
|
||||
// see TxRunner for cross-statement / crash atomicity.
|
||||
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||
|
||||
const int64_t nowMs = m_clock();
|
||||
const std::string nowIso = isoFromMillis(nowMs);
|
||||
|
||||
|
|
@ -193,21 +213,26 @@ public:
|
|||
return;
|
||||
}
|
||||
|
||||
// Update path: insert a historical copy with a new PK, then
|
||||
// update the live row in place by its existing PK.
|
||||
// Update path: compute both rows, then commit the historical copy and
|
||||
// the in-place live update as one unit of work so a failure between
|
||||
// the two can't leave a closed-but-not-replaced or duplicate-live row.
|
||||
auto historical = cloneDto(live);
|
||||
F::id(historical) = m_idgen();
|
||||
F::validUntil(historical) = oatpp::String(nowIso);
|
||||
m_inner->save(historical);
|
||||
|
||||
F::id(dto) = F::id(live); // preserve live PK
|
||||
F::validFrom(dto) = oatpp::String(nowIso);
|
||||
F::validUntil(dto) = oatpp::String(SENTINEL);
|
||||
m_inner->save(dto);
|
||||
|
||||
m_runTx([&] {
|
||||
m_inner->save(historical);
|
||||
m_inner->save(dto);
|
||||
});
|
||||
}
|
||||
|
||||
/** @brief Close the live row without inserting a new version. */
|
||||
void softDelete(const oatpp::String& entityId) override {
|
||||
std::lock_guard<std::mutex> lock(m_writeMutex);
|
||||
auto live = findByEntityId(entityId);
|
||||
if (!live) return;
|
||||
F::validUntil(live) = oatpp::String(isoFromMillis(m_clock()));
|
||||
|
|
@ -247,6 +272,10 @@ private:
|
|||
};
|
||||
}
|
||||
|
||||
static TxRunner defaultTxRunner() {
|
||||
return [](const std::function<void()>& work) { work(); };
|
||||
}
|
||||
|
||||
/// Field-wise deep copy via oatpp's DTO reflection. Used to capture
|
||||
/// the live row's content as the historical copy before the live row
|
||||
/// is updated in place.
|
||||
|
|
@ -264,10 +293,17 @@ private:
|
|||
|
||||
static IdGen defaultIdGen() {
|
||||
return [] {
|
||||
static thread_local std::mt19937_64 rng{std::random_device{}()};
|
||||
// authkit#16 L-5: draw 128 bits straight from the platform CSPRNG
|
||||
// (std::random_device → getrandom()/urandom on Linux) on every
|
||||
// call. The old code seeded a mt19937_64 once from a single
|
||||
// random_device sample, making the whole id stream predictable from
|
||||
// observed outputs — a problem if a consumer ever treats entity_id
|
||||
// as an unguessable handle. Consumers needing a hard guarantee can
|
||||
// still inject their own IdGen.
|
||||
static thread_local std::random_device rd;
|
||||
char buf[33];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
||||
(unsigned long long)rng(), (unsigned long long)rng());
|
||||
std::snprintf(buf, sizeof(buf), "%08x%08x%08x%08x",
|
||||
(unsigned)rd(), (unsigned)rd(), (unsigned)rd(), (unsigned)rd());
|
||||
return oatpp::String(buf);
|
||||
};
|
||||
}
|
||||
|
|
@ -287,6 +323,8 @@ private:
|
|||
std::shared_ptr<Repository<TDto>> m_inner;
|
||||
Clock m_clock;
|
||||
IdGen m_idgen;
|
||||
TxRunner m_runTx;
|
||||
std::mutex m_writeMutex;
|
||||
};
|
||||
|
||||
} // namespace oatpp_authkit::repo
|
||||
|
|
|
|||
36
include/oatpp-authkit/util/ConstantTime.hpp
Normal file
36
include/oatpp-authkit/util/ConstantTime.hpp
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
#ifndef OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
|
||||
#define OATPP_AUTHKIT_UTIL_CONSTANT_TIME_HPP
|
||||
|
||||
// Constant-time comparison (authkit#16 L-7).
|
||||
//
|
||||
// The interceptor looks tokens up by hash in the store (effectively
|
||||
// constant-time via an indexed equality), so it doesn't need this. But a
|
||||
// consumer that ever compares a secret (token, HMAC, hash) in memory must not
|
||||
// use std::string::operator== / memcmp — those short-circuit on the first
|
||||
// mismatching byte and leak, via timing, how much of the secret was guessed.
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace oatpp_authkit {
|
||||
|
||||
/**
|
||||
* @brief Compare two byte strings without an early-exit on the first
|
||||
* differing byte. The length difference is folded into the result, so
|
||||
* unequal-length inputs still take time proportional to the longer one
|
||||
* (the length of a fixed-size hash/token is not itself secret).
|
||||
*/
|
||||
inline bool constantTimeEquals(const std::string& a, const std::string& b) {
|
||||
const std::size_t n = a.size() > b.size() ? a.size() : b.size();
|
||||
volatile unsigned char diff = static_cast<unsigned char>(a.size() ^ b.size());
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const unsigned char ca = (i < a.size()) ? static_cast<unsigned char>(a[i]) : 0;
|
||||
const unsigned char cb = (i < b.size()) ? static_cast<unsigned char>(b[i]) : 0;
|
||||
diff = static_cast<unsigned char>(diff | (ca ^ cb));
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
} // namespace oatpp_authkit
|
||||
|
||||
#endif
|
||||
73
include/oatpp-authkit/util/OriginCheck.hpp
Normal file
73
include/oatpp-authkit/util/OriginCheck.hpp
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
65
include/oatpp-authkit/util/SessionCookie.hpp
Normal file
65
include/oatpp-authkit/util/SessionCookie.hpp
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*
|
||||
* **Server→client 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();
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +191,15 @@ public:
|
|||
{
|
||||
socket.setListener(std::make_shared<Listener>());
|
||||
|
||||
if (!t_pendingAuth.has_value()) {
|
||||
// authkit#16 L-4: consume the thread-local handoff exactly once, up
|
||||
// front, and clear it unconditionally. If a prior connection's
|
||||
// onAfterCreate ever failed to clear it (or oatpp reuses this worker
|
||||
// thread), a leftover value must NOT attach to this socket — and our
|
||||
// own value must not leak to the next connection on this thread.
|
||||
std::optional<SocketInfo> pending = std::move(t_pendingAuth);
|
||||
t_pendingAuth.reset();
|
||||
|
||||
if (!pending.has_value()) {
|
||||
// Should not happen — WSController validates before handshake.
|
||||
OATPP_LOGW("Hub", "WebSocket connected without auth context — closing");
|
||||
try { socket.sendClose(4001, "Unauthorized"); } catch (...) {}
|
||||
|
|
@ -191,14 +212,12 @@ public:
|
|||
// #439: refuse extra connections beyond the cap rather than
|
||||
// allowing unbounded growth of s_sockets / presence maps.
|
||||
OATPP_LOGW("Hub", "socket cap %zu hit — rejecting", kMaxSockets);
|
||||
t_pendingAuth.reset();
|
||||
try { socket.sendClose(1013, "Server Busy"); } catch (...) {}
|
||||
return;
|
||||
}
|
||||
s_sockets[&socket] = std::move(*t_pendingAuth);
|
||||
s_sockets[&socket] = std::move(*pending);
|
||||
s_lastSeen[&socket] = std::chrono::steady_clock::now();
|
||||
}
|
||||
t_pendingAuth.reset();
|
||||
|
||||
OATPP_LOGD("Hub", "client connected: %s (total=%zu)",
|
||||
s_sockets[&socket].username.c_str(), s_sockets.size());
|
||||
|
|
|
|||
|
|
@ -10,6 +10,26 @@ 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_constant_time test_constant_time.cpp)
|
||||
target_link_libraries(test_constant_time PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME constant_time COMMAND test_constant_time)
|
||||
|
||||
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)
|
||||
|
|
@ -50,6 +70,15 @@ add_executable(test_redacted_field_repository test_redacted_field_repository.cpp
|
|||
target_link_libraries(test_redacted_field_repository PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
add_test(NAME redacted_field_repository COMMAND test_redacted_field_repository)
|
||||
|
||||
# SmtpTransport.hpp pulls in <curl/curl.h> and needs libcurl at link time.
|
||||
# Guard the test so the suite still builds where curl dev headers are absent.
|
||||
find_package(CURL QUIET)
|
||||
if(CURL_FOUND)
|
||||
add_executable(test_smtp_transport test_smtp_transport.cpp)
|
||||
target_link_libraries(test_smtp_transport PRIVATE oatpp::authkit oatpp::oatpp CURL::libcurl)
|
||||
add_test(NAME smtp_transport COMMAND test_smtp_transport)
|
||||
endif()
|
||||
|
||||
# RoleTemplateDb pulls in oatpp-sqlite for its DbClient queries. Linking
|
||||
# the test against oatpp::oatpp-sqlite provides the QUERY codegen
|
||||
# definitions; the test itself doesn't open a real DB, only compiles
|
||||
|
|
|
|||
44
test/test_constant_time.cpp
Normal file
44
test/test_constant_time.cpp
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Tests for oatpp-authkit/util/ConstantTime.hpp (authkit#16 L-7).
|
||||
// Verifies functional correctness; timing-invariance is a property of the
|
||||
// branch-free implementation, not asserted here.
|
||||
|
||||
#include "oatpp-authkit/util/ConstantTime.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_constant_time_equals() {
|
||||
REQUIRE(constantTimeEquals("", ""));
|
||||
REQUIRE(constantTimeEquals("abc", "abc"));
|
||||
REQUIRE(constantTimeEquals(std::string(64, 'a'), std::string(64, 'a')));
|
||||
|
||||
REQUIRE(!constantTimeEquals("abc", "abd")); // differ at last byte
|
||||
REQUIRE(!constantTimeEquals("abc", "xbc")); // differ at first byte
|
||||
REQUIRE(!constantTimeEquals("abc", "ab")); // length mismatch (prefix)
|
||||
REQUIRE(!constantTimeEquals("ab", "abc"));
|
||||
REQUIRE(!constantTimeEquals("", "a"));
|
||||
|
||||
// Embedded NUL handled (string-length aware, not C-string).
|
||||
REQUIRE(constantTimeEquals(std::string("a\0b", 3), std::string("a\0b", 3)));
|
||||
REQUIRE(!constantTimeEquals(std::string("a\0b", 3), std::string("a\0c", 3)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_constant_time_equals();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
59
test/test_origin_check.cpp
Normal file
59
test/test_origin_check.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -148,6 +148,29 @@ void test_like_pattern_is_bound_not_interpolated() {
|
|||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
|
||||
}
|
||||
|
||||
void test_like_contains_escapes_wildcards() {
|
||||
// authkit#16 L-8: a user term with %/_/\ must be matched literally via an
|
||||
// explicit ESCAPE clause, not treated as wildcards.
|
||||
auto sql = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likeContains("50%_off\\x"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(sql.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(sql.binds[0]),
|
||||
std::string("%50\\%\\_off\\\\x%"));
|
||||
|
||||
auto pfx = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::name>().likePrefix("a_b"))
|
||||
.toSql();
|
||||
REQUIRE_EQ(pfx.text, std::string(
|
||||
"SELECT * FROM mock_query WHERE name LIKE ? ESCAPE '\\'"));
|
||||
REQUIRE_EQ(std::get<std::string>(pfx.binds[0]), std::string("a\\_b%"));
|
||||
|
||||
// The bare likeEscape helper.
|
||||
REQUIRE_EQ(likeEscape("100%_\\"), std::string("100\\%\\_\\\\"));
|
||||
REQUIRE_EQ(likeEscape("plain"), std::string("plain"));
|
||||
}
|
||||
|
||||
void test_is_null_and_is_not_null() {
|
||||
auto a = Query<MockQueryDto>()
|
||||
.where(field<&MockQueryDto::email>().isNull())
|
||||
|
|
@ -208,6 +231,7 @@ int main() {
|
|||
test_in_with_multiple_values();
|
||||
test_in_with_empty_list_is_always_false();
|
||||
test_like_pattern_is_bound_not_interpolated();
|
||||
test_like_contains_escapes_wildcards();
|
||||
test_is_null_and_is_not_null();
|
||||
test_not_negates_predicate();
|
||||
test_order_by_and_limit_offset();
|
||||
|
|
|
|||
61
test/test_rate_limiter.cpp
Normal file
61
test/test_rate_limiter.cpp
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,7 +275,8 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
},
|
||||
[actor]{ return actor; });
|
||||
[actor]{ return actor; },
|
||||
[](const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; });
|
||||
|
||||
// list filters to allowed rows only.
|
||||
auto allowed = guarded.list();
|
||||
|
|
@ -310,6 +311,113 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
REQUIRE(threwOnDelete);
|
||||
}
|
||||
|
||||
// Scope predicate + entity-id accessor shared by the reparenting / queryable tests.
|
||||
static bool scopeAllows(const oatpp_authkit::repo::ActorContext& a,
|
||||
const oatpp::Object<MockTemporalDto>& d) {
|
||||
if (!d || !d->scope) return false;
|
||||
const std::string s = std::string(*d->scope);
|
||||
for (auto& as : a.allowedScopes) if (as == s) return true;
|
||||
return false;
|
||||
}
|
||||
static oatpp::String entityIdOf(const oatpp::Object<MockTemporalDto>& d) { return d->entity_id; }
|
||||
|
||||
// An actor scoped to prop-A must NOT be able to reparent an existing prop-B row
|
||||
// into prop-A by setting scope=prop-A in the body. save() must reject because the
|
||||
// *existing* row is out of scope, even though the incoming dto looks in-scope.
|
||||
void test_scope_guard_blocks_reparenting() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
|
||||
// Seed an entity currently owned by prop-B.
|
||||
auto seeded = MockTemporalDto::createShared();
|
||||
seeded->id = oatpp::String("pk-ent1");
|
||||
seeded->entity_id = oatpp::String("ent1");
|
||||
seeded->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
seeded->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
seeded->scope = oatpp::String("prop-B");
|
||||
inner->save(seeded);
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardRepository<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
// Attempt to claim ent1 by relabelling it prop-A.
|
||||
auto reparent = MockTemporalDto::createShared();
|
||||
reparent->entity_id = oatpp::String("ent1");
|
||||
reparent->scope = oatpp::String("prop-A"); // incoming looks in-scope...
|
||||
bool blocked = false;
|
||||
try { guarded.save(reparent); }
|
||||
catch (const ScopeDeniedException&) { blocked = true; } // ...but existing row is prop-B
|
||||
REQUIRE(blocked);
|
||||
|
||||
// The stored row is untouched.
|
||||
auto still = inner->findByEntityId(oatpp::String("ent1"));
|
||||
REQUIRE(still);
|
||||
REQUIRE(std::string(*still->scope) == "prop-B");
|
||||
|
||||
// A genuine insert into the actor's own scope still works (no existing row).
|
||||
auto fresh = MockTemporalDto::createShared();
|
||||
fresh->id = oatpp::String("pk-ent2");
|
||||
fresh->entity_id = oatpp::String("ent2");
|
||||
fresh->scope = oatpp::String("prop-A");
|
||||
fresh->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
bool ok = true;
|
||||
try { guarded.save(fresh); } catch (const ScopeDeniedException&) { ok = false; }
|
||||
REQUIRE(ok);
|
||||
}
|
||||
|
||||
// Minimal IQueryable inner whose query() returns every row, so the test can
|
||||
// verify ScopeGuardQueryable post-filters results through the predicate.
|
||||
class InMemoryQueryable : public oatpp_authkit::repo::IQueryable<MockTemporalDto> {
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows)
|
||||
if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
auto v = oatpp::Vector<oatpp::Object<MockTemporalDto>>::createShared();
|
||||
for (auto& kv : rows) v->push_back(kv.second);
|
||||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override { rows[std::string(*dto->id)] = dto; }
|
||||
void softDelete(const oatpp::String&) override {}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>>
|
||||
query(const oatpp_authkit::repo::Query<MockTemporalDto>&) override {
|
||||
return list(); // pretend the filter ran; the point is the guard filters scope
|
||||
}
|
||||
};
|
||||
|
||||
// query() through ScopeGuardQueryable must drop rows outside the actor's scope —
|
||||
// otherwise the queryable surface bypasses the scope guard entirely.
|
||||
void test_scope_guard_queryable_filters_query() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryQueryable>();
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc);
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
dto->scope = oatpp::String(sc);
|
||||
inner->save(dto);
|
||||
}
|
||||
|
||||
ActorContext actor;
|
||||
actor.userId = "u1";
|
||||
actor.allowedScopes = {"prop-A"};
|
||||
|
||||
ScopeGuardQueryable<MockTemporalDto> guarded(
|
||||
inner, &scopeAllows, [actor]{ return actor; }, &entityIdOf);
|
||||
|
||||
auto result = guarded.query(Query<MockTemporalDto>{});
|
||||
REQUIRE(result->size() == 1); // prop-B filtered out
|
||||
REQUIRE(std::string(*(*result)[0]->scope) == "prop-A");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -319,6 +427,8 @@ int main() {
|
|||
test_history_returns_versions_in_order();
|
||||
test_soft_delete_closes_live_without_new_version();
|
||||
test_scope_guard_denies_when_predicate_false();
|
||||
test_scope_guard_blocks_reparenting();
|
||||
test_scope_guard_queryable_filters_query();
|
||||
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
|
|
|
|||
75
test/test_session_cookie.cpp
Normal file
75
test/test_session_cookie.cpp
Normal 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;
|
||||
}
|
||||
75
test/test_smtp_transport.cpp
Normal file
75
test/test_smtp_transport.cpp
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Tests for oatpp-authkit/mail/SmtpTransport.hpp.
|
||||
//
|
||||
// Covers the pure, network-free surface:
|
||||
// - base64Encode against RFC 4648 vectors
|
||||
// - hasHeaderInjectionChars
|
||||
// - send() rejects CR/LF/NUL in recipient / from address BEFORE touching
|
||||
// libcurl (the SMTP header-injection guard) — no live mail server needed,
|
||||
// the validation short-circuits ahead of curl_easy_init / perform.
|
||||
|
||||
#include "oatpp-authkit/mail/SmtpTransport.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::mail;
|
||||
|
||||
void test_base64_rfc4648_vectors() {
|
||||
REQUIRE(base64Encode("") == "");
|
||||
REQUIRE(base64Encode("f") == "Zg==");
|
||||
REQUIRE(base64Encode("fo") == "Zm8=");
|
||||
REQUIRE(base64Encode("foo") == "Zm9v");
|
||||
REQUIRE(base64Encode("foob") == "Zm9vYg==");
|
||||
REQUIRE(base64Encode("fooba") == "Zm9vYmE=");
|
||||
REQUIRE(base64Encode("foobar") == "Zm9vYmFy");
|
||||
}
|
||||
|
||||
void test_header_injection_detector() {
|
||||
REQUIRE(!hasHeaderInjectionChars("a@b.com"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\r\nBcc: evil@x.com"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\n"));
|
||||
REQUIRE( hasHeaderInjectionChars("a@b.com\r"));
|
||||
REQUIRE( hasHeaderInjectionChars(std::string("a@b.com\0x", 9))); // embedded NUL
|
||||
}
|
||||
|
||||
void test_send_rejects_crlf_in_addresses() {
|
||||
SmtpConfig cfg;
|
||||
cfg.host = "localhost";
|
||||
cfg.fromAddress = "noreply@example.com";
|
||||
|
||||
// CRLF in recipient → rejected with no network call.
|
||||
std::string r1 = send("victim@example.com\r\nBcc: evil@x.com",
|
||||
"subject", "<p>hi</p>", {}, cfg);
|
||||
REQUIRE(r1.find("invalid recipient") != std::string::npos);
|
||||
|
||||
// CRLF in from address → rejected.
|
||||
SmtpConfig cfg2 = cfg;
|
||||
cfg2.fromAddress = "noreply@example.com\r\nSubject: spoofed";
|
||||
std::string r2 = send("victim@example.com", "subject", "<p>hi</p>", {}, cfg2);
|
||||
REQUIRE(r2.find("invalid from") != std::string::npos);
|
||||
|
||||
// Empty-config guards still fire (and come before the address checks).
|
||||
SmtpConfig empty;
|
||||
REQUIRE(send("a@b.com", "s", "b", {}, empty).find("no host") != std::string::npos);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_base64_rfc4648_vectors();
|
||||
test_header_injection_detector();
|
||||
test_send_rejects_crlf_in_addresses();
|
||||
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
67
test/test_token_extract.cpp
Normal file
67
test/test_token_extract.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue