Repository navigation
2.2.0 — registry of rejected (unsent) forms
Added
- Registry of rejected (unsent) forms (
FormSpamChecker\Integration\RejectedStore):
when the drop-in guard blocks a submission — a spam verdict inenforcemode
or a WAF block, plus an opt-insuspiciousverdict that never blocked — the
full submission is persisted before the blocking response: request
method/URI/query/scheme/host/URL, form key, site, verdict with every check
outcome, client (IP, User-Agent, Referer, Origin, Accept-Language), raw POST
fields (values untrimmed, arrays preserved), upload metadata (name, size, MIME,
SHA-256; bytes optional) and the headers needed to replay. One JSON file per
record, written atomically, directories0700/ files0600with a
.htaccessdeny; bounded byrejected.retention_days,
rejected.max_records_per_siteandrejected.max_bytes_per_record
(truncation marker +truncated=true). Fail-open: a registry error never
blocks or unblocks a form. bin/rejected-forms—list(triage columns: site, form, score, status,
e-mail/phone present, first reason),show <id>(masked by default,
--revealprints personal data),replay <id>(dry run by default,
--confirmsends, original rejection reason printed first, same-host guard
with--allow-host-change),ignore <id> --reason=,
purge --older-than=DAYS [--confirm];--jsonfor machines; exit codes
0/1/2/3 documented in the file header.FormSpamChecker\Integration\ReplayRequest— rebuilds the replayable
request (method, URL, urlencoded or multipart body, replay-relevant headers)
and performs it through a stream context. Replayed uploads become
multipart/form-dataonly from stored bytes; Cookie/Authorization are never
replayed; a record whose uploads were not stored is refused instead of being
sent incomplete.- Configuration — new
rejectedblock (enabled,store_payload,path,
retention_days,max_records_per_site,max_bytes_per_record,
store_files,capture_suspicious,store_headers_secrets), enabled by
default. The derived directory isdirname(dirname(storage.path))/rejected/<site>,
i.e.var/rejected/<site>for the shared guard — a separate top-level tree,
not the per-site counter/cache tree.
Security
- The registry deliberately stores personal data (that is what makes a
replay possible), unlike the pseudonymised verdict log wherelog.piistays
false. Access is controlled by filesystem permissions (0700/0600,
.htaccessdeny for Apache, tree kept outside the document root) and by
retention_days;bin/rejected-formsmasks fields, bodies, query strings and
user-agents until--revealis passed on purpose. - Cookie, Authorization, Proxy-Authorization and X-Api-Key are dropped unless
rejected.store_headers_secretsis enabled for inspection, and are never
replayed. Replay refuses a different host without--allow-host-change.
Notes
- Timestamps are stored twice on purpose:
tsis ISO-8601 with an explicit
offset (date('c'), server timezone),ts_utcis the same instant in UTC
with aZsuffix, andts_epochis used for sorting. Do not treat one as the
other: mixing local time labelledZwith UTC (a trap seen in the company
portal, wheretickets.created_atdrifted three hours from
ticket_events.created_at) makes a rejection timeline unusable. - Existing enforce/WAF test fixtures now disable
rejectedso the suite writes
no registry files; the registry itself is covered by the new
RejectedStoreTest/ReplayRequestTest/RejectedFormsCommandTestwith a
positive capture fixture and clean/suspicious decoys.
Full changelog: https://github.com/Paulmann/PHP-Form-Spam-Checker/blob/main/CHANGELOG.md