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>
196 lines
6.3 KiB
C++
196 lines
6.3 KiB
C++
// Tests for authkit#15 — RedactedFieldRepository decorator.
|
|
|
|
#include "oatpp-authkit/repo/RedactedFieldRepository.hpp"
|
|
#include "oatpp-authkit/repo/Repository.hpp"
|
|
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
|
#include "oatpp-authkit/repo/TemporalRepository.hpp"
|
|
|
|
#include "oatpp/core/macro/codegen.hpp"
|
|
#include "oatpp/core/Types.hpp"
|
|
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#define REQUIRE(cond) do { \
|
|
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
|
|
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
|
|
|
|
#include OATPP_CODEGEN_BEGIN(DTO)
|
|
|
|
namespace {
|
|
|
|
class CredDto : public oatpp::DTO {
|
|
DTO_INIT(CredDto, DTO)
|
|
DTO_FIELD(String, id);
|
|
DTO_FIELD(String, entity_id);
|
|
DTO_FIELD(String, valid_from);
|
|
DTO_FIELD(String, valid_until);
|
|
DTO_FIELD(String, username);
|
|
DTO_FIELD(String, passwordHash);
|
|
DTO_FIELD(String, tlsCertDn);
|
|
};
|
|
|
|
} // namespace
|
|
|
|
#include OATPP_CODEGEN_END(DTO)
|
|
|
|
OATPP_AUTHKIT_REGISTER_TEMPORAL(CredDto, id, entity_id, valid_from, valid_until)
|
|
|
|
namespace {
|
|
|
|
using namespace oatpp_authkit::repo;
|
|
|
|
// In-memory inner that just records what got saved, for inspection.
|
|
class FakeInner : public Repository<CredDto> {
|
|
public:
|
|
std::vector<oatpp::Object<CredDto>> saved;
|
|
|
|
oatpp::Object<CredDto> findByEntityId(const oatpp::String&) override { return nullptr; }
|
|
oatpp::Vector<oatpp::Object<CredDto>> list() override {
|
|
return oatpp::Vector<oatpp::Object<CredDto>>::createShared();
|
|
}
|
|
void save(const oatpp::Object<CredDto>& dto) override { saved.push_back(dto); }
|
|
void softDelete(const oatpp::String&) override {}
|
|
};
|
|
|
|
oatpp::Object<CredDto> makeRow(const std::string& vu,
|
|
const std::string& password,
|
|
const std::string& certDn) {
|
|
auto d = CredDto::createShared();
|
|
d->id = "id1";
|
|
d->entity_id = "ent1";
|
|
d->valid_from = "2026-01-01T00:00:00Z";
|
|
d->valid_until = vu;
|
|
d->username = "alice";
|
|
d->passwordHash = password;
|
|
d->tlsCertDn = certDn;
|
|
return d;
|
|
}
|
|
|
|
void test_live_row_passes_through_unchanged() {
|
|
auto inner = std::make_shared<FakeInner>();
|
|
RedactedFieldRepository<CredDto> redacted(
|
|
inner, {"passwordHash", "tlsCertDn"});
|
|
|
|
// Live row: valid_until == SENTINEL.
|
|
auto live = makeRow(TemporalRepository<CredDto>::SENTINEL,
|
|
"$bcrypt$secret", "CN=alice");
|
|
redacted.save(live);
|
|
|
|
REQUIRE(inner->saved.size() == 1);
|
|
auto& got = inner->saved[0];
|
|
REQUIRE(got->passwordHash);
|
|
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
|
|
REQUIRE(got->tlsCertDn);
|
|
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
|
|
}
|
|
|
|
void test_historical_row_redacts_named_fields() {
|
|
auto inner = std::make_shared<FakeInner>();
|
|
RedactedFieldRepository<CredDto> redacted(
|
|
inner, {"passwordHash", "tlsCertDn"});
|
|
|
|
// Historical row: valid_until is a real timestamp, not the sentinel.
|
|
auto historical = makeRow("2026-05-06T12:00:00Z",
|
|
"$bcrypt$secret", "CN=alice");
|
|
redacted.save(historical);
|
|
|
|
REQUIRE(inner->saved.size() == 1);
|
|
auto& got = inner->saved[0];
|
|
REQUIRE(!got->passwordHash); // redacted to null
|
|
REQUIRE(!got->tlsCertDn); // redacted to null
|
|
// Non-redacted fields survive.
|
|
REQUIRE(got->username);
|
|
REQUIRE(std::string(*got->username) == "alice");
|
|
REQUIRE(got->valid_until);
|
|
REQUIRE(std::string(*got->valid_until) == "2026-05-06T12:00:00Z");
|
|
}
|
|
|
|
void test_partial_redaction_list() {
|
|
auto inner = std::make_shared<FakeInner>();
|
|
RedactedFieldRepository<CredDto> redacted(inner, {"passwordHash"});
|
|
|
|
auto historical = makeRow("2026-05-06T12:00:00Z",
|
|
"$bcrypt$secret", "CN=alice");
|
|
redacted.save(historical);
|
|
|
|
auto& got = inner->saved[0];
|
|
REQUIRE(!got->passwordHash); // redacted
|
|
REQUIRE(got->tlsCertDn); // NOT redacted (not in list)
|
|
REQUIRE(std::string(*got->tlsCertDn) == "CN=alice");
|
|
}
|
|
|
|
void test_empty_redaction_list_passes_everything_through() {
|
|
auto inner = std::make_shared<FakeInner>();
|
|
RedactedFieldRepository<CredDto> redacted(inner, {});
|
|
|
|
auto historical = makeRow("2026-05-06T12:00:00Z",
|
|
"$bcrypt$secret", "CN=alice");
|
|
redacted.save(historical);
|
|
|
|
auto& got = inner->saved[0];
|
|
REQUIRE(got->passwordHash);
|
|
REQUIRE(std::string(*got->passwordHash) == "$bcrypt$secret");
|
|
REQUIRE(got->tlsCertDn);
|
|
}
|
|
|
|
void test_null_valid_until_treated_as_live() {
|
|
auto inner = std::make_shared<FakeInner>();
|
|
RedactedFieldRepository<CredDto> redacted(
|
|
inner, {"passwordHash", "tlsCertDn"});
|
|
|
|
// valid_until null — treat as live (the temporal decorator hasn't
|
|
// set it yet on a fresh insert, before deciding sentinel).
|
|
auto fresh = CredDto::createShared();
|
|
fresh->id = "id2";
|
|
fresh->entity_id = "ent2";
|
|
fresh->passwordHash = "$bcrypt$fresh";
|
|
fresh->tlsCertDn = "CN=bob";
|
|
redacted.save(fresh);
|
|
|
|
auto& got = inner->saved[0];
|
|
REQUIRE(got->passwordHash); // not redacted
|
|
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() {
|
|
test_live_row_passes_through_unchanged();
|
|
test_historical_row_redacts_named_fields();
|
|
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;
|
|
}
|