donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-08-17 11:04:03 +02:00
commit abbd616b40
23 changed files with 2898 additions and 209 deletions

View file

@ -50,6 +50,29 @@ int main() {
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
// A repeated field keeps BOTH pairs and Get answers with the first. Most
// urlencoded parsers in the wild take the last, so this is pinned rather
// than left to the header comment: every refusal in ValidateCheckout reads
// its field through Get, and flipping this to "last wins" would silently
// hand a second `country=` the final say over where a parcel may go.
{
auto dup = parse("country=NL&country=RU");
Check(dup->Size() == 2, "form: a repeated field keeps both pairs");
Check(dup->Get("country") == "NL", "form: duplicates resolve to the first");
}
// Field NAMES are percent-decoded, not only values — %73 is 's', so
// `web%73ite` is the field `website`. The honeypot below is found by its
// decoded name and nothing else, so this is what makes the trap closed
// against a bot that encodes the key it is trying to avoid.
{
auto encodedName = parse("web%73ite=spam");
Check(encodedName->Has("website"), "form: a percent-encoded field name decodes");
Check(encodedName->Get("website") == "spam",
"form: the value still attaches to the decoded name");
}
// A field name is not allowed to be empty — "=x" is malformed, not a field.
Check(!parse("=x").has_value(), "form: empty field name rejected");
// Oversized input must be refused outright rather than truncated: acting on
@ -129,6 +152,18 @@ int main() {
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
&& pot.errors[0].message.find("website") == std::string::npos,
"checkout: honeypot failure does not name the trap");
// The same trap with the trigger field's NAME percent-encoded, which is the
// obvious way to try to slip past it. It still fails closed only because
// ParseUrlEncoded decodes the key before Get looks it up, and the refusal
// has to stay identical — a different answer for the encoded spelling would
// itself tell a bot which spelling worked.
{
auto sneaky = validate(std::string(kGoodOrder) + "&web%73ite=http%3A%2F%2Fspam");
Check(!sneaky.Ok(), "checkout: honeypot catches a percent-encoded field name");
Check(sneaky.errors.size() == 1 && sneaky.errors[0].field.empty()
&& sneaky.errors[0].message == "Submission rejected.",
"checkout: the encoded-name trap gives the same single generic refusal");
}
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
+ "&street=x&postal=1&city=y&country=NL").Ok(),
@ -150,6 +185,45 @@ int main() {
"checkout: past the technical ceiling rejected");
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
"checkout: non-numeric quantity rejected");
// "two" fails at the first character, which is the easy half. The hard half
// is a valid numeric PREFIX: from_chars consumes what it can, reports
// success, and leaves the leftovers to the caller — so the only thing
// standing between "2x" and a two-unit charge is the check that parsing
// reached the end of the field. Quantity multiplies the unit price into
// what the buyer actually pays, so a partial parse is a billing bug.
{
auto trailing = validate(std::string(kGoodOrder) + "&quantity=2x");
Check(!trailing.Ok(), "checkout: a numeric prefix with trailing junk rejected");
Check(trailing.errors.size() == 1 && trailing.errors[0].field == "quantity",
"checkout: the quantity refusal hangs off the quantity field");
// Rejected means rejected, not "keep what we managed to read": the
// parsed 2 must not survive into value, because value is what the
// handler prices if anything upstream ever ignores Ok().
Check(trailing.value.quantity == 1,
"checkout: a rejected quantity resets to 1, not the parsed prefix");
}
// from_chars for an integer stops at 'e' and at '.', so left unchecked each
// of these would be read as a bare 1 rather than refused — and "1e3" is a
// spelling of 1000 that no form control produces.
Check(!validate(std::string(kGoodOrder) + "&quantity=1e3").Ok(),
"checkout: exponent notation rejected rather than partly read");
Check(!validate(std::string(kGoodOrder) + "&quantity=1.5").Ok(),
"checkout: a fractional quantity rejected rather than truncated");
// "-1" parses cleanly all the way to the end, so it survives the prefix
// check and is caught by the range floor instead.
Check(!validate(std::string(kGoodOrder) + "&quantity=-1").Ok(),
"checkout: a negative quantity rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=-1").value.quantity == 1,
"checkout: a negative quantity never reaches the record");
// Trim runs before from_chars, so surrounding whitespace is not junk:
// "%20" decodes to a space and " 2" trims back to "2". A buyer who pastes
// a padded number is not making a hostile submission.
Check(validate(std::string(kGoodOrder) + "&quantity=%202").Ok(),
"checkout: a leading space on the quantity is trimmed, not rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=%202").value.quantity == 2,
"checkout: the trimmed quantity is the one that counts");
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
"checkout: oversized colour rejected");
@ -210,6 +284,21 @@ int main() {
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
}
// Parameter pollution against the order gate. Every refusal above reads its
// field through Fields::Get, which takes the FIRST of a repeated pair, so
// appending a second value cannot reopen a destination the first one closed
// — kGoodOrder already carries country=nl, and the trailing RU is inert.
// The same property protects the number the buyer is charged for.
{
auto polluted = validate(std::string(kGoodOrder) + "&country=RU");
Check(polluted.Ok(),
"checkout: a trailing second country cannot displace the first");
Check(polluted.value.country == "NL",
"checkout: the first country is the one validated and stored");
Check(validate(std::string(kGoodOrder) + "&quantity=2&quantity=99").value.quantity == 2,
"checkout: a second quantity cannot raise what is charged");
}
// The shipping refusals. These are templates rather than plain strings
// because the buy page fills the same ones client-side, so the substitution
// has to work on both {cc} and {n} — a template that silently kept its
@ -249,6 +338,78 @@ int main() {
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
// ── the euro-amount parser ────────────────────────────────────────
// Exact integer parsing for the one amount that ever arrives from a
// client (the donation). Same no-floats rule as every money path.
Check(ParseEuroAmountToMinor("25") == 2500, "amount: whole euros");
Check(ParseEuroAmountToMinor("12.50") == 1250, "amount: euros and cents");
Check(ParseEuroAmountToMinor("12,50") == 1250, "amount: comma decimal mark");
Check(ParseEuroAmountToMinor("2.5") == 250, "amount: one decimal is tenths, not cents");
Check(ParseEuroAmountToMinor("0.01") == 1, "amount: a single cent parses");
Check(ParseEuroAmountToMinor("10000") == 1000000, "amount: the ceiling parses");
Check(!ParseEuroAmountToMinor("").has_value(), "amount: empty rejected");
Check(!ParseEuroAmountToMinor("-5").has_value(), "amount: negative rejected");
Check(!ParseEuroAmountToMinor("1e3").has_value(), "amount: exponent rejected");
Check(!ParseEuroAmountToMinor("1.234").has_value(), "amount: third decimal rejected");
Check(!ParseEuroAmountToMinor("1.2.3").has_value(), "amount: two marks rejected");
Check(!ParseEuroAmountToMinor(".50").has_value(), "amount: bare fraction rejected");
Check(!ParseEuroAmountToMinor("25 EUR").has_value(), "amount: trailing text rejected");
Check(!ParseEuroAmountToMinor("12345678901").has_value(), "amount: oversized rejected");
// ── donation validation ───────────────────────────────────────────
// Its own validator, not checkout with fields waived: nothing ships, so
// no address is even asked for, and email is optional — the order page's
// capability URL is already the receipt.
auto donate = [](std::string_view body) {
return ValidateDonation(*ParseUrlEncoded(body));
};
{
auto ok = donate("amount=25");
Check(ok.Ok(), "donation: an amount alone is a complete submission");
Check(ok.value.amountMinor == 2500, "donation: the amount lands in cents");
Check(ok.value.quantity == 1, "donation: quantity is always one");
Check(ok.value.email.empty(), "donation: no email means no email");
}
Check(donate("amount=12.50&email=a%40b.example").Ok(),
"donation: an email may ride along for the confirmation");
Check(donate("amount=12.50&email=a%40b.example").value.amountMinor == 1250,
"donation: cents survive alongside the email");
Check(!donate("amount=25&email=nonsense").Ok(),
"donation: a present-but-bad email is still refused");
Check(!donate("email=a%40b.example").Ok(), "donation: no amount, no donation");
Check(!donate("amount=nonsense").Ok(), "donation: an unparseable amount is refused");
Check(!donate("amount=0.99").Ok(), "donation: below the €1 floor refused");
Check(donate("amount=1").Ok(), "donation: the €1 floor itself is welcome");
Check(donate("amount=10000").Ok(), "donation: the €10,000 ceiling itself is welcome");
Check(!donate("amount=10000.01").Ok(), "donation: past the ceiling refused");
{
// Rejected means rejected: the out-of-range figure must not survive
// into value, because value is what the handler charges if anything
// upstream ever ignores Ok().
auto big = donate("amount=99999");
Check(big.value.amountMinor == 0,
"donation: a refused amount never reaches the record");
Check(big.errors.size() == 1 && big.errors[0].field == "amount",
"donation: the refusal hangs off the amount field");
}
// The same honeypot as checkout, reported just as namelessly.
{
auto pot2 = donate("amount=25&website=spam");
Check(!pot2.Ok(), "donation: honeypot rejects");
Check(pot2.errors.size() == 1 && pot2.errors[0].field.empty()
&& pot2.errors[0].message == "Submission rejected.",
"donation: honeypot failure does not name the trap");
}
// The payment choice, same rules as checkout.
Check(donate("amount=25&pay=crypto").value.payChoice == Catcrafts::Form::kPayCrypto,
"donation: crypto choice parsed");
Check(!donate("amount=25&pay=free").Ok(), "donation: unknown payment choice rejected");
// First-wins duplicates protect the amount exactly as they protect
// checkout's quantity: a trailing second value cannot raise the charge.
Check(donate("amount=25&amount=9999").value.amountMinor == 2500,
"donation: a second amount cannot displace the first");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;