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>
141 lines
5.4 KiB
C++
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
|