oatpp-authkit/include/oatpp-authkit/repo/ScopeGuardRepository.hpp
Uwe Schuster 2e11408240 #16 (audit H-1..H-5): fix the five high-severity findings
- H-1 cert-DN spoofing: IRuntimeConfig::certAuthTrusted() now defaults to
  false (fail-closed). X-SSL-Client-DN is an ordinary request header; a
  loopback bind does not prove it came from a TLS-terminating proxy.
  Consumers must opt in explicitly behind a header-stripping proxy.

- H-3 scope reparenting: ScopeGuardRepository::save() now also checks the
  EXISTING row's scope (via a new required entity-id accessor), so an actor
  can't claim an out-of-scope row by relabelling it in the request body.

- H-2 IQueryable bypass: add ScopeGuardQueryable<T> — filters query()
  results through the same predicate so the queryable surface can't escape
  the scope guard.

- H-4 TemporalRepository TOCTOU: serialise the read-modify-write with a
  per-instance mutex (no more duplicate-live / lost-update under concurrent
  same-entity saves) and add an optional TxRunner so the close-then-insert
  pair can commit/rollback atomically.

- H-5 SMTP header injection: reject CR/LF/NUL in `to`/`fromAddress` before
  building the envelope and From:/To: header lines.

Tests: expand test_repository_decorators (reparenting + queryable filtering),
add curl-guarded test_smtp_transport (base64 vectors + CRLF guard). All 15
ctest targets pass. README updated.

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

210 lines
8.8 KiB
C++

#ifndef OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_SCOPE_GUARD_REPOSITORY_HPP
#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"
#include <functional>
#include <memory>
#include <stdexcept>
#include <utility>
namespace oatpp_authkit::repo {
/**
* @brief Thrown when the scope guard predicate denies an operation.
*
* Catchers (typically the controller layer) translate this into the
* appropriate HTTP error — 403 Forbidden in fewo-webapp's case. The
* decorator stays library-portable by throwing a plain exception rather
* than coupling to oatpp's `OatppException` hierarchy.
*/
class ScopeDeniedException : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
/**
* @brief Decorator that gates every repository operation on a predicate.
*
* Generic — knows nothing about "property" / "tenant" / any consumer-
* specific scope concept. The predicate decides; this class just calls it.
*
* @section semantics Per-method behaviour
*
* - `findByEntityId(id)`: load from inner; if non-null and predicate
* denies, throw `ScopeDeniedException`. (Information-leak vs. clean
* 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 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()>;
/// 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
/// composes cleanly into `SchemaBuilder<…>` parameter packs.
inline static constexpr DecoratorSchema kSchema = {
"ScopeGuardRepository",
nullptr, 0,
nullptr, 0,
nullptr, 0,
};
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
Predicate isAllowed,
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 {
auto row = m_inner->findByEntityId(entityId);
if (!row) return row;
if (!m_isAllowed(m_currentActor(), row)) {
throw ScopeDeniedException("scope guard denied findByEntityId");
}
return row;
}
oatpp::Vector<oatpp::Object<TDto>> list() override {
auto inAll = m_inner->list();
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
const ActorContext actor = m_currentActor();
for (auto& row : *inAll) {
if (m_isAllowed(actor, row)) out->push_back(row);
}
return out;
}
void save(const oatpp::Object<TDto>& dto) override {
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);
}
void softDelete(const oatpp::String& entityId) override {
auto row = m_inner->findByEntityId(entityId);
if (!row) return; // Nothing to delete; matches Repository<T>::softDelete being a no-op for unknown ids.
if (!m_isAllowed(m_currentActor(), row)) {
throw ScopeDeniedException("scope guard denied softDelete");
}
m_inner->softDelete(entityId);
}
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
#endif