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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:53:22 +02:00

141 lines
5.4 KiB
C++

#ifndef OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_REDACTED_FIELD_REPOSITORY_HPP
// Decorator that nulls out named fields on historical rows (authkit#15).
//
// Use case: when password_hash and similar credentials ride a temporal
// row, every change creates a historical version with the prior secret
// preserved. A DB breach then yields every credential the user has ever
// had — a known-plaintext oracle for guessing future passwords.
//
// This decorator sits **between** TemporalRepository and the concrete
// repo. TemporalRepository's `save` flow calls inner `save` twice:
//
// 1. Historical clone with `valid_until = now()` (the row being closed)
// 2. Live row with `valid_until = SENTINEL` (the new version)
//
// We redact configured fields whenever `valid_until != SENTINEL` on
// entry — i.e. only on the historical insert. The live row keeps its
// values intact.
//
// Stack:
//
// TemporalRepository<UserDto>(
// RedactedFieldRepository<UserDto>(
// ConcreteUserRepository(udb),
// {"passwordHash", "tlsCertDn"}))
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp/core/Types.hpp"
#include <memory>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that redacts named fields on historical rows.
*
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation so
* the decorator can read `valid_until` to distinguish historical rows
* from live ones. Field-name matching uses oatpp's reflective property
* dispatcher and matches against the **C++ identifier** name (the first
* macro argument to `DTO_FIELD`), not the JSON-qualified column name.
*
* Schema contribution: empty. The redaction is purely a save-time
* transform; no extra columns or indexes are needed.
*/
template <typename TDto>
class RedactedFieldRepository : public Repository<TDto> {
public:
inline static constexpr DecoratorSchema kSchema = {
"RedactedFieldRepository",
nullptr, 0, nullptr, 0, nullptr, 0,
};
/**
* @param inner Concrete adapter (or any further-inner stack).
* @param fieldsToRedact C++ identifier names of DTO fields to null
* out on historical writes (e.g. `"passwordHash"`).
*/
RedactedFieldRepository(std::shared_ptr<Repository<TDto>> inner,
std::vector<std::string> fieldsToRedact)
: m_inner(std::move(inner))
, 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);
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
return m_inner->list();
}
void save(const oatpp::Object<TDto>& dto) override {
if (isHistorical(dto)) redactFields(dto);
m_inner->save(dto);
}
void softDelete(const oatpp::String& entityId) override {
m_inner->softDelete(entityId);
}
private:
/// A row is historical iff `valid_until` is non-null and not the
/// SENTINEL. The TemporalRepository sets `valid_until = now()` on
/// the close-clone and `valid_until = SENTINEL` on the live update.
static bool isHistorical(const oatpp::Object<TDto>& dto) {
auto& vu = TemporalFieldTraits<TDto>::validUntil(dto);
if (!vu) return false;
return std::string(*vu) != TemporalRepository<TDto>::SENTINEL;
}
void redactFields(const oatpp::Object<TDto>& dto) const {
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (auto* p : dispatcher->getProperties()->getList()) {
const std::string name(p->name);
for (const auto& target : m_fieldsToRedact) {
if (name == target) {
p->set(static_cast<oatpp::BaseObject*>(dto.get()),
oatpp::Void(nullptr, p->type));
break;
}
}
}
}
std::shared_ptr<Repository<TDto>> m_inner;
std::vector<std::string> m_fieldsToRedact;
};
} // namespace oatpp_authkit::repo
#endif