Working notes for this codebase: the checks to run before shipping, and the history of the CSP gap that is now closed — kept because the reasoning is what stops it being reopened, and because the conversion that closed it is still the main source of regressions.
Last reviewed: 2026-10-26 (v1.12.8).
Run these before any push that touches more than one PHP file, and again against
the production container after a deploy. Each takes about two seconds. There is
no php binary on the build host, so anything invoking it runs in a container.
docker exec gamenight-dev sh -c 'find /var/www/html -name "*.php" | while read f; do php -l "$f" 2>&1 | grep -v "^No syntax errors"; done'A PHP parse error takes down the whole file, not the branch containing it, so
the page returns an empty 500 on every request. This exists because v0.2064
rewrote the style.css cache-buster across all 54 pages that link one, and in
timer.php that link sat inside a PHP string where the short-echo tag is not
evaluated and its quotes closed the string. The timer was a white page for the
entire release. Any pattern-based edit across many files will eventually hit a
context where the pattern is wrong.
Related trap: never write the PHP closing tag inside a // comment. It ends PHP
mode and reintroduces the same class of fatal.
cd ~/qa-headless && node inline_js_sweep.jsSweep 1 cannot see this. Most of the JavaScript in this app is emitted from
inside .php files — checkin.php alone renders about 3,500 lines of it — and
php -l is perfectly happy with PHP that prints broken JS. The failure is total
rather than local: one SyntaxError discards the entire <script> block, so
every function it declared is undefined and the whole console goes dead. There
is no partial degradation and no error on screen; the page simply renders and
nothing works.
The sweep fetches each rendered page, extracts every inline <script> (skipping
src= tags, which are real files a plain node --check already covers) and
parse-checks each one, so it reads exactly what the browser is asked to parse.
It walks 22 pages, logged out for the two that are reachable that way. Add a
page to its PAGES list whenever a new one starts generating JS — a page that
is not listed is not checked, and the sweep will still report all-clear.
It exists because an edit inserted a statement between an if and its else:
if (!PAYOUT_STRUCTURES.length) loadPayoutStructures();
refreshPresetState(); // ← inserted here
else renderPayoutStructureSelect();php -l passed. The file looked right in a diff. The check-in console was
completely non-functional, and the first symptom was an unrelated test failing
on SESSION is not defined — the global was never declared because the block
containing it never parsed.
Any insertion into generated JS deserves this sweep: the surrounding lines you
are editing may be PHP-interpolated, brace-matched across a <?php ?> boundary,
or (as above) one half of a control-flow statement that a diff does not show you.
If a change touches a shared file — _footer.php, _nav.php, auth.php,
db.php, anything under www/*.js — the blast radius is every page that
includes it, not the file you edited. Run the full set in ~/qa-headless, not
just the suite for the page you were working on.
This is not hypothetical: v0.2077 added pk-dispatch.js to _footer.php and
shipped, which made every control in the check-in console fire twice for a whole
release. The suite that detects it (checkin_args.js, reporting calls: 2) was
not run, because checkin.php had not been edited.
cd ~/qa-headless && node double_dispatch_sweep.jsWalks 37 pages and, for every data-act* control and generic behaviour,
fires one event and asserts the handler ran exactly once. It catches the
v0.2078 class of bug: two dispatchers on a page, a duplicated listener, or a
script included twice. It also flags a control whose named function does not
resolve, i.e. a dead control.
A control that fires twice is silent on anything idempotent and fatal on
anything that toggles, which is why it survived a whole release before a user hit
it on a phone. Run this after any change to a shared partial or to
pk-dispatch.js.
Note checkin.php legitimately loads the shared script and sets
window.PK_DISPATCH_LOCAL; the flag makes the shared one return early, and the
sweep reports it as standing down rather than as a fault.
It needs an admin session, and it says so. For its first several releases the
sweep ran as a plain user, so every admin page returned 403 and reported zero
controls — 296 of 558 controls silently unchecked, including five files that
later turned out to be broken. It now probes admin_settings.php first and fails
loudly if it is unreachable. Promote the dev test user, run, then put it back:
docker exec gamenight-dev php -r '(new PDO("sqlite:/var/db/app.db"))->exec("UPDATE users SET role=\"admin\" WHERE id=269");'
cd ~/qa-headless && node double_dispatch_sweep.js
docker exec gamenight-dev php -r '(new PDO("sqlite:/var/db/app.db"))->exec("UPDATE users SET role=\"user\" WHERE id=269");'The generic behaviours need watching differently from data-act*: data-confirm
names no function, so there is nothing to count. The sweep stubs
pkConfirmForm() and asserts exactly one call per data-confirm form and
button. Without that it passed a release in which every delete on the home feed
raised two stacked dialogs.
grep -rnE "addslashes\(htmlspecialchars\(|on[a-z]+=\"[^\"]*<\?=[[:space:]]*json_encode|value=\"'\+[a-zA-Z_]" www/ --include=*.php \
| grep -v "escAttr(\|escHtml("The trailing filter matters: the third pattern also matches correctly-escaped
code such as value="'+escAttr(col)+'", and a check that reports clean code as a
problem quickly gets ignored. On a clean tree the whole command prints nothing.
grep -rn '<?= json_encode(' www/ --include=*.phpPrints nothing on a clean tree. A value written into <script> as
var X = <?= … ?>; goes through js_json() (db.php), never bare
json_encode(). The HTML parser reads a script block before JavaScript does:
json_encode() escapes /, so </script> cannot appear, but <!-- and
<script> can, and that pair moves the tokenizer into its "double escaped"
state so the real </script> no longer ends the block (the page's next script
tag does), which turns the block into a SyntaxError and discards every
function in it. An event title is enough to do it, on every console the title
is shown on. js_json() carries JSON_HEX_TAG and friends, substitutes an
invalid UTF-8 byte instead of returning false (which printed var X = ;, the
same outcome), and prints null as the last resort. Extra flags such as
JSON_FORCE_OBJECT are passed through: js_json($v, JSON_FORCE_OBJECT). This
is the script-block rule; the attribute rule below is different (an attribute
holds data, not JSON).
grep -rnE "LOWER\((u|users)\.username\) = LOWER\((ei|event_invites|i|a|b)\.username\)|LOWER\((ei|event_invites|i|a|b)\.username\) = LOWER\((u|users)\.username\)" www/ --include=*.php \
| grep -v "ONE-SHOT BACK-FILL"Prints nothing on a clean tree. The one sanctioned match is the one-shot back-fill
in db_init(), which carries that marker as a SQL comment and runs once, under
the event_invites_user_id_backfilled setting. An invite's account is event_invites.user_id,
set by the host at write time (resolve_invite_user_id(), or the caller's own
id); a username is user-chosen, so matching on it at run time lets whoever
registers a name that a host typed become that invitee (reminders, the comment
thread, the roster row their results attach to, and for a typed-in manager the
event's controls). The join was removed from the authority checks in the first
hardening and crept back into ten readers and the API's writers by v1.8.4; the
v1.8.5 pass cleared them. Any new reader selects ei.user_id or joins
users u ON u.id = ei.user_id; any new writer records user_id. The alias list
grew in v1.12.3 when the audit found the join alive under i in two more places.
grep -rnE -A8 "LOWER\(([a-z_]+\.)?username\) *= *LOWER\((\?|:[a-z_]+)\)" www/ --include=*.php \
| grep -E "\\\$(current|user|cu|me|sender|recipient|viewer)\['username'\]"A statement that compares username with a bound value, followed within eight
lines by an execute that binds the signed-in (or phone-resolved) account's
username. Prints nothing on a clean tree. Three rules, from the 2026-10-24 audit
(fixed in v1.12.3):
- Which invite is mine? is
ei.user_id = <account id>: never the signed-in account's username, the phone-resolved account's username (the SMS and WhatsApp webhooks), or an API caller's user_id turned into a username. - Which account does this invite belong to? is
ei.user_id(JOIN users u ON u.id = ei.user_id); NULL means no account. - A queued notification is addressed to
pending_notifications.user_id(the caller's account id, or the invite row's own link copied when it was queued), else to the contact details on the invite row.dispatch_queued_notification()never resolves a username to an account.
The bound-parameter form LOWER(username) = LOWER(?) is still legitimate in two
places, and the sweep leaves them alone: a host acting on a roster name inside an
event they already control (approve, deny, resend, the check-in console's
poker_players.display_name writes), where the name identifies a row and not an
account; and resolving a typed name to an account at write time
(resolve_invite_user_id(), canonical_username(), add_walkin), which is the
host's decision. event_notifications_sent.user_identifier is a dedup log keyed
by lowercase name on purpose and is not a username comparison, so the sweep
does not see it. Before v1.12.3 this sweep printed 33 lines: My Events, the RSVP
and sign-up paths, two manager checks, the DM
picker and the webhooks all found "my" invite by a name a stranger could
register, and the notification sender handed that stranger the reminders and
one-click RSVP links meant for the person the host typed.
Anything it prints is a real bug, not a style preference. In order, it flags:
| Pattern | Why it is wrong |
|---|---|
addslashes(htmlspecialchars($v)) |
htmlspecialchars turns ' into ' first, so addslashes finds no quote to escape and is a no-op. The browser decodes the entity back to a quote before compiling an inline handler, so the JS string still breaks. |
on…="… <?= json_encode($v) ?> …" |
json_encode() emits its own " delimiters, which terminate the surrounding attribute. Everything after is parsed as further attributes on the element. |
value="'+x+'" in JS-built markup |
An unescaped value closes the attribute and injects markup into the DOM. |
All three shipped to production and were fixed in v0.2070 and v0.2071. The first two also caused a visible functional bug: the admin delete confirmation silently did nothing for any event whose title contained an apostrophe.
Correct forms — and note that "attribute context" is now two different things:
| Context | Correct form |
|---|---|
PHP, attribute holding JS source (onclick="fn(…)") |
htmlspecialchars(json_encode($v), ENT_QUOTES | ENT_SUBSTITUTE) — historical only, no such attributes remain |
PHP, attribute holding data (data-a1, data-confirm, title) |
htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE), or a cast such as (int)$id |
JS in timer_classic.php, attribute context |
escAttr($v) |
| JS, text context | escHtml($v) |
PHP, value inside a <script> block |
js_json($v) (sweep 2d) |
JS, text passed to pkConfirm() / pkAlert() / pkConfirmForm() |
the message is rendered as HTML: a name, title or other typed value goes through escHtml() (or the file's own escaper) first; pk-dispatch.js escapes data-confirm text itself |
The json_encode rule is a trap now. It was correct while attributes carried
JS source: onclick="viewEvent(<?= json_encode($ev) ?>)" needed a JS literal,
and the encoder produced one. After the handler conversion, data-a* attributes
carry data — pk-dispatch.js reads the attribute and passes the string
through, with no parse step. A json_encode()d object therefore arrives at the
handler as text.
This is not hypothetical. calendar.php followed the documented rule:
data-act="viewEvent" data-a1="<?= htmlspecialchars(json_encode($ev)) ?>"viewEvent(ev) read ev.id off a string, so every event chip in the month
view navigated to /event.php?id=undefined until v0.2083. Pass the scalars the
handler actually needs (data-a1="<?= (int)$ev['id'] ?>"), never an object.
escHtml() escapes <, > and & but not quotes, so it is safe for text
nodes and unsafe for attributes. That distinction is the whole reason escAttr()
exists.
Always pair ENT_QUOTES with ENT_SUBSTITUTE. On its own, ENT_QUOTES
makes htmlspecialchars() return an empty string when the input contains an
invalid UTF-8 byte. An event title carrying one produced an empty data-confirm,
so the delete confirmation silently did not appear (fixed across 8 files in
v0.2082). html_entity_decode() is unaffected and correctly does not use it.
grep -rnE 'data-[a-z0-9-]+="[^"]*""|data-[a-z0-9-]+="[^"]*<\?=|data-[a-z0-9-]+="[^"]*" \+ |data-[a-z0-9-]+="<\?= *htmlspecialchars\(json_encode' www/ --include=*.phpPrints nothing on a clean tree. Every hit is a real defect. Converting an inline
handler means retyping an attribute by hand, and the four ways that goes wrong
are all invisible to php -l and to a browser — the page renders, the control
just misbehaves.
| Pattern | What it means |
|---|---|
data-x="…"" |
a stray duplicated closing quote; everything after it is parsed as a further attribute |
data-x="…<?= |
a PHP tag that got HTML-escaped during conversion, so the literal <?= … ?> shows in the dialog |
data-x="…" + |
a JS concatenation emitted inside a single-quoted JS string, so ' + i + ' lands in the markup verbatim |
data-x="<?= htmlspecialchars(json_encode( |
an object passed where the dispatcher will deliver a string (see the escaping table above) |
Measured, not assumed: against the tree as it shipped before v0.2082 this prints 20 hits across 11 files — the entire markup half of that batch, four of its nine findings. On the fixed tree it prints nothing. It runs in well under a second and needs no container and no browser, so there is no reason to skip it.
It does not catch the other five (a duplicated listener, a wrong query
parameter, a missing data-stop check, a JSON-quoted key, a missing
ENT_SUBSTITUTE) — those need sweep 1c or a browser. Use both.
Word-boundary warning. If you write an ad-hoc grep for handlers, anchor it.
on[a-z]+=" matches action=, position= and direction=, and reported 130
inline handlers on a tree that has zero. Use [[:space:]]on[a-z]+=" and discard
comment lines. A check that cries wolf gets ignored, which is worse than not
having it.
The first thing in the tree that both calls another server with a credential and accepts signed requests from it. What holds it:
- The game key (
sso_apps.api_key) is stored withencrypt_value()underAPP_SECRET, decrypted only insidefinaltable_request()for theAuthorization: Bearerheader, and never echoed: the admin page selects its columns by name and renders Set / Not set; the activity log records that a key was set, never the key.CURLOPT_FOLLOWLOCATIONis off, so a redirect cannot carry the bearer to another host. Every outbound call is a person pressing a button behindcsrf_verify()andcan_manage_event()(or an admin on the Connected Apps page); there is no queue that could replay one. - The webhook secret is per game: 48 hex characters from
random_bytes(), sent to FinalTable once at creation and kept encrypted infinaltable_games.webhook_secret. It never reaches a browser (finaltable_public_game()strips it). - The receiver (
finaltable_webhook.php) fails closed the waysms_webhook.phpdoes, with an explicit$verifiedflag: POST only, body capped at 64 KB and read once, a millisecond timestamp within 300 s (a seconds comparison copied from the SMS receiver rejects everything — that was the first bug), asha256=<64 hex>signature, thenhash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret), $sig)with the raw header string and the body exactly as sent. The game row is looked up from the unverified body'sgame.idbefore the check because the secret is per game; nothing else is read until the signature passes. An unknown id is a 404 on purpose (a deleted event's stragglers; FinalTable gives up after a day and logs it there). Dedupe is an INSERT intofinaltable_deliverieswith a UNIQUE(game_id, delivery_id): the constraint is the lock, a repeat answers 200 and does nothing. It never includesauth.php, so no session, CSRF or CSP is in play. - What the panel's
stateread gives back is filtered by the caller, not left to the page to hide: the join code and link only for someone on the guest list (a manager, or an approved invite matching their account id - the same testevent.phpmakes), the FinalTable roster and the seat-by-seat entrants only for a manager, a bareseatedcount for everyone else, and the finishing order never - it stays inlast_statusfor the server-rendered line and Manage Game.event_visibility_sql()alone is too loose here: on a public or league event it is true for every signed-in member, so a read authorised only by it would have walked past the organiser's hide guest list setting. - What it writes is bounded:
finaltable_gamescolumns and thepoker_playersrows of the one session tied to that game, matched byuser_id(never by the name in the payload) and only for an id on the roster this side sent, which is kept as JSON onfinaltable_games.rosterwhen the table is set up. Without that test a signed delivery naming a stranger would have this side make them a player and give them an approvedevent_invitesrow — which is an authorisation grant, sinceevent_visibility_sql()reads it. Ids that were never sent are refused and logged once per delivery, however many a standings list names. A game set up before the column existed hasrosterNULL, which means "do not check", so a table already running keeps recording; that fallback can go a release later. A player it does have to create gets an invite row beside it sosync_invitees()cannot soft-remove it. - The watch link is not gated, and that is the decision. The
stateread withholds the roster and the entrants from a non-manager, but the rail link goes to every viewer of the event and FinalTable's rail shows the field by name. Gating the button would not make the night private — every invitee already has the link by email, and the link is pass-on-able by design on both sides — so hide guest list binds the guest list on the event page and nothing else. DOCS.md says so where a host will read it. - The new
data-actcontrols (ftSetup,ftControl,ftCancel,ftRemove,ftCopy) live infinaltable.jsand render only when the event carriesonline_app_id; give a dev event one before the double-dispatch sweep, or it walksevent.phpwithout them.
auth.php now sets:
script-src 'self' 'nonce-<per-request>'
script-src-elem 'self' 'nonce-<per-request>'
script-src-attr 'none'
An injected <script> is refused because it cannot carry the nonce, and an
injected on* attribute is refused because attribute handlers are disallowed
outright. CSP is now a real second line of defence against XSS, which it was
not for any of the findings in the v0.2070 review.
The nonce is carried on script-src as well as script-src-elem deliberately:
browsers without CSP3 granularity fall back to script-src, and a nonce there
makes 'unsafe-inline' ignored, so they get the same guarantee rather than none.
'self' stops an attacker loading script from another origin. 'unsafe-inline'
additionally permits script written inside the HTML, covering both <script>
blocks and onclick="…" attributes. Because the browser cannot distinguish the app's own inline script from an
injected one, the CSP originally provided no XSS mitigation at all — every
XSS issue found in the v0.2070 review would have executed without friction.
v0.2072 closed half of it (injected <script> elements are refused by the
nonce) and v0.2079 closed the rest (script-src-attr 'none', once the tree
reached zero inline handlers). The paragraphs below describe the intermediate
states because the reasoning is what keeps the fix from being undone, not
because any of it is still live.
'unsafe-inline' was load-bearing until the very end. The tree today:
| Count | |
|---|---|
Inline on* handler attributes |
0 (was 401; converted across v0.2073-v0.2078) |
Inline <script> blocks |
64, all nonced as of v0.2072 |
External .js files |
7 (pk-dispatch.js added in v0.2077) |
At the start checkin.php alone had 165 inline handlers and timer.php 157, so
dropping the directive on day one would have broken the application completely.
That is why the path below was split into three independently shippable steps
rather than one flag change.
Verify the count with the anchored pattern, not a bare one:
grep -rEh '[[:space:]]on[a-z]+="' www/*.php www/*.js | grep -vE '^\s*(//|\*)'A nonce authorizes a <script> block. There is no syntax to nonce an on*
attribute — inline event handlers cannot be authorized by nonce at all. They
have to become addEventListener with their data carried in data- attributes.
Note the trap: adding a nonce to script-src makes a browser ignore
'unsafe-inline' entirely, which blocks event handler attributes too. Done
naively that breaks every button on the site. The split is what makes step 1
shippable on its own:
The intermediate split that made step 1 shippable on its own — superseded by the policy at the top of this section, kept to explain the sequencing:
| Directive (v0.2072-v0.2078, no longer in use) | Purpose |
|---|---|
script-src 'self' 'unsafe-inline' |
fallback for browsers without CSP3 granularity (Firefox); behaviour there is unchanged |
script-src-elem 'self' 'nonce-…' |
script elements must carry the nonce, so an injected <script> is refused |
script-src-attr 'unsafe-inline' |
event handler attributes stay allowed until they are converted |
Most of this codebase's handlers are built inside JS strings at runtime, e.g.
h += '<button onclick="toggleBuyin(' + p.id + ')">';which is exactly the pattern that has to move to event delegation.
Add a per-request nonce via the three-directive split above.Done in v0.2072.auth.phpmints a per-requestCSP_NONCE;csp_nonce()stamps it on all 64 inline blocks across 33 files. External<script src="/...">needs no nonce, it is covered by'self'. A standalone page that sends no CSP header of its own must not callcsp_nonce();cast_receiver.phpwas the only one and has since been deleted as dead code. What this buys: an injected<script>element is now refused by the browser. What it does not: an injectedon*attribute still runs, sincescript-src-attrstill allows them. That is what step 2 closes.Convert handlers file by file.DONE as of v0.2078 — the tree is at zero. The pattern below is kept because any new markup must follow it. Started in v0.2073:_nav.php(7) and_post_card.php(9) are at zero. The established pattern:- Tag the control with a
data-*attribute carrying whatever the old handler passed as arguments (data-nav="dropdown",data-pc="toggle-comments" data-post="12"). - Dispatch from a delegated listener on
document, not a per-element binding. Delegation is required, not stylistic:posts_chunk.phpinjects cards by AJAX after load, and per-element listeners would miss them. - Prefer an existing external
.jsfile as the destination (nav.js). External scripts are covered by'self'and need no nonce. Otherwise put the listener in the page's nonced block, next to the functions it calls. - For
onsubmit="return pkConfirmForm(...)"use the genericdata-confirm="…" data-confirm-ok="…" data-confirm-danger="1"handled by one delegatedsubmitlistener.pkConfirmForm()callsformEl.submit(), which does not re-fire the submit event, so it cannot recurse. - If the destination asset is not cache-busted, bust it in the same change.
Moving behaviour into a
.jsfile turns a stale cached copy from an out-of-date file into a broken feature: the new markup emitsdata-*attributes the old file has never heard of, and the control silently does nothing. This shipped as v0.2073 and was fixed in v0.2074 —nav.jswas the only local script tag without a?v=and the entire nav went dead for anyone holding a cached copy. Every local script tag now carries?v=<?= APP_VERSION . '.' . filemtime(...) ?>; keep it that way. The same trap hitstyle.cssin v0.2062. - Namespace argument attributes per event. One element can carry two
handlers (the walk-in box has both
inputandkeydown). A shareddata-a1..a4namespace produces a duplicate attribute, HTML keeps the first, and the second handler silently receives the wrong arguments. Click uses the baredata-aN; every other event prefixes its own (data-keydown-a1). This shipped in v0.2073's pattern and broke Enter-to-add-walk-in plus Enter in cash-in fields; fixed in v0.2075. - Test the actual gesture, not just the dispatch. A sweep that reads the same attributes the dispatcher reads will agree with a bug rather than catch it. The collision above survived 315 passing dispatch assertions and was found by a human pressing Enter. Add one real-gesture test per interaction class (type-and-Enter, click, select-change).
- Use the shared
www/pk-dispatch.jsfor any page that includes_footer.php. It is external, so it needs no nonce, and it carries generic declarative behaviours for the idioms that recur:data-confirm/data-confirm-ok/data-confirm-dangeron a form,data-href,data-toggle-class="id:class",data-click-file="inputId",data-select-all-on-focus,data-uppercase. Reach for those before writing yet another near-identical named helper.checkin.phpandtimer_classic.phpcarry their own copies from before it existed; leave them. The Tournament Timer (timer.php) has no shared dispatcher at all and reads its owndata-tbattributes, which is why it does not claim the shareddata-actname. - Delegate in the CAPTURE phase. An ancestor that calls
stopPropagation()makes a bubble-phase listener ondocumentblind to everything beneath it.timer.php's control tray does exactly that, so the play, sound and level buttons dispatched nothing until the listeners moved to capture. Inline handlers were immune because they run at the target; capture restores that ordering. - Count with a complete
on[a-z]+=pattern. An enumerated list of event names will miss some:ondragstart/ondragover/ondrop/ondragendwere absent from the first sweep, sotimer.phpwas reported as 154 rather than 157 and drag-to-reorder would have been left inline. - Capture a before/after baseline of the live DOM. Snapshot every element carrying a handler, convert, snapshot again, and diff on (element, event). That is what proves nothing was dropped; a passing dispatch sweep only proves that what remains works.
- A change to a shared partial changes every page that includes it, so run
the suites for the pages AFFECTED, not the files EDITED. v0.2077 added
pk-dispatch.jsto_footer.phpand shipped;checkin.phpincludes that footer and already had its own dispatcher, so every control in the check-in console fired twice for a full release. Toggles are where this is fatal and silent: tapping a player card ranclassList.toggle('open')twice so it opened and shut again, and ticking buy-in set it on then off. The dispatch sweep did detect it (calls: 2on 289 of 294 controls) — it simply was not run, becausecheckin.phphad not been edited. The user found it on a phone before the suite did. If a change touches_footer.php,_nav.php,auth.php,db.phpor any file underwww/*.js, re-run everything. - One dispatcher per page.
checkin.phpandtimer.phpcarry their own and setwindow.PK_DISPATCH_LOCAL = 1;pk-dispatch.jsreturns early when it sees that flag. Do not add a second dispatcher to a page without the same guard. - When testing a conversion, make sure the fixture actually exists. A check that skips because no post card rendered will report a pass for work it never verified.
- Verify with the old asset forced back in (Playwright
page.route()can serve the previous file) to prove the cache-buster is what fixes it, rather than assuming.
- Tag the control with a
DropDONE in v0.2079. Verified before switching: a DOM scan across 36 pages found zeroscript-src-attr 'unsafe-inline'.on*attributes in rendered markup (including JS-built) and zero unnonced inline<script>blocks. After switching, an injectedon*handler and an injected nonce-less<script>are both confirmed blocked, while every nonced inline block still runs.
Anything added from here must follow the conversion pattern below, because
an inline handler will now simply not fire. pk-dispatch.js logs
[pk-dispatch] no handler named X when a name does not resolve, and the sweeps
in the pre-push section catch the rest.
The sweeps above are no longer a compensating control for an open gap — the gap is shut. What they now protect is the conversion: 401 hand-edited attributes produced nine defects that a parse check could not see, four of them still live weeks later. They are the difference between catching that in two seconds and catching it in a security review months later.
Recorded because roughly half the fixes are structural (future code inherits them) and half are conventions someone has to follow.
Structural, no discipline required:
sanitize_html()now walks the subtree of an element it is about to unwrap, so every caller gets correct sanitization.- Event authority resolves through
event_invites.user_idrather than a username string match. Since v1.8.5 so does everything else that turns an invite into an account: roster imports, notification recipients, SMS matching and the API (sweep 2b keeps it that way). Since v1.12.3 the reader's side too: a signed-in account finds its own invite by id, and a queued notification carries the account it is addressed to (sweep 2c). - Account self-service (v1.12.4): a signed-in session is not proof enough to
re-home an account.
confirm_current_password()inauth.phpgates every change of username, email or phone, two-factor enrolment, recovery-code regeneration, disabling two-factor, a new password and deleting the account (five wrong guesses lock those prompts for 15 minutes). A new email waits inusers.pending_emailuntil the link sent to it is used (email_verifications.new_email,verify_email.php), and the old address is told. A changed phone is unverified andsend_mfa_sms_code()refuses to text it; the sign-in page asks for a recovery code instead.users.session_epochis bumped by every password change or reset (invalidate_other_sessions()) andcurrent_user()signs out any session carrying the old value.session.use_strict_modeis on, andcomplete_login()discards the anonymous session's CSRF token so the signed-in session mints its own. - League membership follows approval (v1.12.5): on a league event an approved
invite that has an account is a member, a pending or denied one is not.
walkin.phpenrols only an approved walk-in, and every path that approves an invite later callsleague_enrol_if_approved()indb.php. - Pending league and contact rows are claimed by a PROVEN address only
(v1.12.5):
claim_verified_contact()inauth.phpruns from the verification pages, the Surge code in Settings and admin imports, never fromregister_user();league_invite.phplinks a signed-in account only through a verified email or phone. The unlinkedregister_dl.phpcopy of the sign-up page, which had no attempt limit, is gone. - One local-redirect rule (v1.12.5):
safe_local_path()inauth.phpaccepts a single leading slash followed by printable ASCII only, so//host,/\hostand/<tab>/host(folded by browsers into//host) are all refused;referer_local_path()applies it to "go back" redirects. login, register, comment, the league post and API-key endpoints and the calendar all use it. - Read-write gaps closed in v1.12.6: an event comment needs the event visible to
the commenter (
event_visibility_sql(), as event.php); the event editor's contact picker gives a plain league member names only and never a pending contact's typed details; adding a registered account to a league creates an invitation addressed to that account (league_invite_account()indb.php,league_members.invited_user_id, accepted only by that account onleague_invite.php, the row carrying only what the inviter typed) instead of enrolling it; per-player league history is members-only; a timer-layout save or delete unlinks only the caller's own uploads (u<uid>_prefix) and never a file a chip set still names (pk_lo_gc_images()); push endpoints must be a known push service or an admin-listed host (wp_endpoint_allowed()), ten per account, 3 s connect / 5 s total, https only, the test button rate-limited; a payout structure loads only through the list's own visibility filter (pk_payout_structure_for()). - The Low list, first half (v1.12.7): every MFA text is counted
(
send_mfa_sms_code()refuses past five in fifteen minutes, activity_logmfa_sms_send) and every verification code per account (send_verification_code(), five an hour); a phone number already registered gets the DECOY code session (verify_user_id = -1, same answers as a real signup) so the verify page is not a lookup; every SMS event command needs a verified number (sms_admin.php); the API log stores the canonical path (api_canonical_path()), so a direct-file call or a trailing slash counts against the same per-endpoint limit, and the create limits match exactly; API answers areprivate(a cached GET) orno-store; contacts' save responses no longer say whether a typed address matches an account; TV pairing codes are minted at most thirty an hour per address, always unique (tv_pair_new()throws rather than duplicate), misses ontv_send.phpare counted per address, and the "sent" banner names only a game the caller runs;connect.phprefuses a missing or malformedstate; the FinalTable webhook logs one bad-signature line per game and address per five minutes; a missing or unwritable app secret stops the site (db.php) instead of minting a per-request one; every CSV export goes throughcsv_cell();privacy.phpandterms.phpcarry the headers; the service worker opens only a path on this site; script-block JSON goes throughjs_json()(sweep 2d); the dispatchers (pk-dispatch.js,checkin.php,timer_classic.php) call only an identifier-named, page-defined function, never a browser built-in;data-confirmtext is escaped before the dialog; the timer's custom-element maps have no prototype and the sevenObject.prototypenames are refused on save. - The Low list, second half (v1.12.8):
get_client_ip()returnsREMOTE_ADDRand nothing else (mod_remoteip in the Dockerfile already rewrites it fromX-Real-IPfor connections from the Docker bridge, so a header from anywhere else now counts for nothing); the SMS log keeps outbound bodies throughredact_secrets()(six-digit codes and link tokens blanked) and the mail retry queue stores bodies throughencrypt_value(); the cron token travels in theX-Cron-Tokenheader (?token=kept for an external cron) and reachescron_drain.phpthrough the environment, never an argument;cron_token,waha_api_keyandvapid_private_pemare inENCRYPTED_SETTINGS(one-shotsettings_encrypted_v2re-saved the stored values);sanitize_html()allows noclassoridand only the propertiessanitize_style()lists (no position, z-index, display, opacity, transform orurl()); Jodit is pinned byJODIT_VERSIONin the entrypoint with a.versionmarker so a bump reaches an existing install; Apache's twelve workers and the 384 MB container limit are sized together. - Message and comment pictures (v1.13.0):
messages_dl.phpsendandcomment.phpaddapplyupload.php's checks (real bytes,getimagesize(), 8 MB, the daily cap) before anything touches disk, name the fileu<uid>_<32 hex>.<ext>underuploads/dm/oruploads/comments/(unguessable, owner in the name, served static with execution off), anddm_unlink_attachments()(_dm.php) /comment_unlink_attachments()(db.php) remove the files on every path that deletes the rows (a comment, post, event or account deleted, the last member leaving a group);cron.php's sweep treatsdm_messages.attachmentandcomments.attachmentas references. A league's known-as name (league_members.known_as, v1.13.0) is set only by an owner or manager throughupdate_member, capped at 60 characters, and shown only on the league's own pages (never the public page). username_format_error()inauth.phpis the single username rule, applied by registration, the API and the profile editor.pk_theme_sanitize_props()constrains stored theme JSON on every write path.
Conventions that must be followed by new code:
- Attribute escaping (see the table above).
escAttr()for JS-built attributes in the timer.- Scope filters and copy-on-edit on any new theme or preset action in
timer_dl.php, matching whatget_theme/get_presets/delete_themealready do. - State-changing endpoints are POST plus
csrf_verify(); a GET may render a confirmation but must not write. Seersvp.php,verify_email.php,poll.phpandjoin_league.php. form-action 'self'stays. The one exception isconnect.php, whose confirm POST answers with a redirect to a connected app, and Chromium checksform-actionagainst that redirect target (Firefox does not, which is how a single-browser test misses it). It callscsp_allow_form_action_to()with the origin of the app's registeredsso_apps.base_url, never with anything from the request, and the helper validates the origin to a barescheme://host[:port]before splicing it into the header. Do not widen it to*or call it from any other page.- A redirect target that comes from a request parameter is validated
origin-exact (
sso_validate_return()in_sso.php: scheme, host and port equal, path under the registered path, no fragment or userinfo) and a failure renders an error page rather than redirecting anywhere. A prefix compare on the string is not a check:http://a.comis a prefix ofhttp://a.com.evil.com/.