Date: 2026-05-07
Scope: full repo at main (commit 3728106), against SPEC.md as of the same
SHA. Focus is on real correctness, security, robustness, and maintainability
issues — not cosmetics, naming, or stylistic nits.
This document is meant to be referenced on each fix loop alongside SPEC.md. Findings are ordered by severity (highest first) and each carries:
R01-Nxx) — quote when committing a fix.open / accepted-by-design / fixed-in-<sha> (update as we
resolve).When a finding is closed, set Status to fixed-in-<sha> and append a short
note. Do not delete entries — they're history.
open finding whose severity ≥ MEDIUM.fixed-in-<sha> and add the SHA + a one-line note..env857df15 — src/Auth/LocalAdmin.php now reads
LOCAL_ADMIN_PASSWORD_HASH and verifies with password_verify().
Per the operator's explicit choice this is a clean break: NO plaintext
LOCAL_ADMIN_PASSWORD fallback. LocalAdmin::isEnabled() returns false
until the hash is provided, so /auth/local 404s instead of silently
re-using stale plaintext config. Email comparison stays timing-safe
(hash_equals, trimmed). Hash recipe documented in README.md Quick
setup step 3 and doc/admin-manual.md §3.5 (host-PHP and
docker run php:8.3-cli one-liners). New tests/Auth/LocalAdminTest.php
(9 cases, +13 assertions) covers the happy path, wrong-password /
wrong-email rejection, and an explicit regression guard that the legacy
LOCAL_ADMIN_PASSWORD env var alone does NOT enable the fallback.src/Auth/LocalAdmin.php lines 51-66 (verify(), password()).env.example lines 25-33 (the comment acknowledging the trade-off)hash_equals(self::password(), $password). There is
no hashing at rest. Anyone who can read .env (compromised backup, leaked
Docker image layer, mis-configured docroot, container exec, debugging
session) gets immediate admin..env world-readable on the host) becomes a
full-app compromise. The app already supports OIDC, so this fallback is the
weakest link.LOCAL_ADMIN_PASSWORD_HASH instead.
Verify with password_verify(). Offer a one-liner in the admin manual to
generate the hash.LOCAL_ADMIN_PASSWORD
accepted with a logged warning for one release, then removed)./7fd849b — twig guard tightened from
currentUser is null or currentUser.isAdmin to
currentUser is not null and currentUser.isAdmin; new
TwigViewTest::testHomeForAnonymousUserHidesRuntimePanel asserts the
panel and its leaked strings are absent for currentUser=null. Closes
R01-N31 as a side effect (the in-page /healthz hint lived inside the
same <details>).views/home.twig lines 112-138 — the "Runtime" <details>
panel.currentUser is null, the panel still renders. It exposes:
PHP version (PHP_VERSION), APP_ENV, the absolute path of the SQLite
file, the schema version, OIDC configured y/n, local-admin enabled y/n.currentUser is not null and
currentUser.isAdmin. Keep /healthz as the public liveness probe (text
"ok").f565c86 — went with the explicit env-bootstrap
branch. New src/Auth/BootstrapAdmin.php exposes
isConfigured() + matches(oid, email), reading the new
BOOTSTRAP_ADMIN_OID / BOOTSTRAP_ADMIN_EMAIL env vars
(case-insensitive, trimmed, hash_equals). AuthController::callback
now gates promotion on users.countAdmins() === 0 &&
BootstrapAdmin::matches($oid, $email) — with both env vars blank the
OIDC path NEVER auto-promotes (operators must seed via the local-admin
fallback or by flipping is_admin in app.sqlite). Switching from
count() === 0 to countAdmins() === 0 also closes the SQLite
DEFERRED-tx race the original finding called out: even if two
flows concurrently see the same count, the BootstrapAdmin match
eliminates the "wrong user wins" outcome — only the configured
principal is ever promoted. The local-admin path is unchanged: it
is itself an explicit env-bootstrap (LOCAL_ADMIN_EMAIL +
LOCAL_ADMIN_PASSWORD_HASH) and forceAdmin: true continues to
keep the configured local user admin. Both paths' BOOTSTRAP_ADMIN
audit rows now carry via=oidc / via=local so /audit can
distinguish them. Operator docs: new doc/admin-manual.md §3.5
"Nominating the first administrator (OIDC)" (old §3.5 → §3.6),
README's quick-setup paragraph rewritten, .env.example gains
the new env block. New tests/Auth/BootstrapAdminTest.php (8
cases) pins the matcher: unconfigured, oid-only, email-only,
both-set, case-insensitive trim, blank-incoming-fields-don't-match,
whitespace-only-env-treated-as-unset, either-channel-wins.
Tests: 184 / 502 (was 176 / 484).src/Controllers/AuthController.php lines 86-116 (OIDC) and 217-254
(local).src/Repositories/UserRepository.php lines 50-78.users.count() === 0 inside the same transaction as the upsert, but
SQLite default BEGIN is DEFERRED, so two concurrent first-time logins
could both observe count=0 and both get promoted. More importantly: if the
app is reachable on the public internet before the intended admin signs
in, an attacker with a valid Entra account in the tenant (or the local
admin password if that path is enabled) can win the race./users, which is itself admin-only. If the
attacker is the first user, the legit operator has no path back without
database access.BOOTSTRAP_ADMIN_OID or
BOOTSTRAP_ADMIN_EMAIL) and only auto-promote when the signing user
matches.BEGIN IMMEDIATE for the upsert tx to serialise the check
(cheap on SQLite WAL).doc/admin-manual.md regardless.SESSION_SECRET env var is documented but unused296883c — removed from .env.example,
SPEC.md §8, README.md, and doc/admin-manual.md §3.3 (subsequent
subsections renumbered). Took the "remove it" path called out in the
Suggested-fix list. Existing deployments' .env files keep their dead
SESSION_SECRET= line; harmless, can be deleted at any time..env / .env.example line 11.src/Auth/SessionGuard.php — no reference.SESSION_SECRET is documented as the salt for "session cookie
name / CSRF tokens" but nothing in the code reads it. CSRF tokens are
sourced from random_bytes(32) (good); the session id is whatever PHP
generates (good). The env var is dead..env.example will
assume rotating SESSION_SECRET invalidates active sessions / CSRF
tokens. It does not. False sense of security; rotations don't actually
protect against the threats they imagine they're protecting against..env.example, SPEC §8, and the admin manual; ORsession_id() derivation, or use it to
HMAC-derive CSRF tokens so deploy-time rotation is meaningful.a2e77ea — paired with R01-N07. New
src/Http/TrustedProxies.php reads TRUSTED_PROXIES (comma-separated
CIDRs); Request::isHttps() honours X-Forwarded-Proto: https only when
REMOTE_ADDR is in a trusted CIDR (so an off-net attacker cannot lie
their way into Secure-cookie / HSTS behaviour). SessionGuard::start()
now marks the cookie Secure when either APP_BASE_URL is HTTPS or
the live request is effectively HTTPS — a TLS-terminating proxy that
forwards over plain HTTP no longer downgrades the cookie. Multi-hop
X-Forwarded-Proto lists ("https, http") read leftmost-first per the
RFC 7239 convention. public/index.php adds a 308 HTTP→HTTPS redirect
when APP_BASE_URL is HTTPS and the request is provably HTTP — i.e.
no TRUSTED_PROXIES configured at all (so REMOTE_ADDR is the user) or
a trusted proxy explicitly reported X-Forwarded-Proto: http. The
redirect is deliberately suppressed when the proxy is silent about the
scheme, to avoid an infinite-loop with TLS-terminating proxies that
forgot to forward the header. /healthz is exempt outright. Operator
docs: new .env.example TRUSTED_PROXIES= block, new admin-manual
§3.5 "Reverse proxy and HTTPS" with the required Nginx
proxy_set_header lines (old §3.5/§3.6 shifted to §3.6/§3.7). New
tests/Http/TrustedProxiesTest.php (14 cases) and RequestTest.php
(4 cases) cover proxy trust gating, IPv6 CIDRs, port stripping, and
the multi-hop XFP list. Tests: 202 / 533 (was 184 / 502).public/index.php lines 220, 245-247 — HSTS only when APP_BASE_URL
starts with https://.src/Auth/SessionGuard.php lines 41-58 — secure cookie flag mirrors
the same flag.APP_BASE_URL is set to an http:// URL, the session
cookie's secure flag is dropped and HSTS is not emitted. A reverse-proxy
misconfig (e.g., terminating TLS at the proxy but not setting
X-Forwarded-Proto) silently degrades to HTTP behaviour.X-Forwarded-Proto when behind a known proxy (config-driven trust
list) and treat the request as HTTPS for cookie / HSTS purposes.public/index.php when APP_BASE_URL
is HTTPS.doc/admin-manual.md.e295432 — new migration 005_auth_throttle.sql
AuthThrottleRepository keyed on (ip_address, email). loginLocal
now checks lockoutFor() BEFORE LocalAdmin::verify() (so a slow
password_verify() can't be turned into an oracle while locked); on
failure it calls recordFailure(), on success it clear()s the bucket.
Policy in the pure-static computeLockout(attempts, now): 1-4 → no
lock, 5-9 → +5 min, 10-19 → +30 min, 20+ → +1 hour. Counter rolls
over after 15 minutes of no failures, so honest mistypes don't get
punished forever. Email key is lowercased + trimmed so case variation
doesn't dodge the bucket; IPs separate, so two attacker IPs don't
share a lock. Throttle hits emit a LOGIN_FAILED audit row with
reason=local_admin_throttled_until_<iso> and the form renders an
amber "Too many failed attempts" notice when redirected with
?throttled=1. New tests/Repositories/AuthThrottleRepositoryTest.php
(13 cases) pins the threshold matrix, the 4:59 / 5:00 lockout
boundary, the idle-window reset, IP / email bucketing, and
purgeExpired()'s selectivity. Tests: 176 / 484 (was 163 / 417).src/Controllers/AuthController.php::loginLocal lines 194-276./auth/local.
Attackers can brute-force the password as fast as the server can respond.
The audit log records LOGIN_FAILED rows (forensics value), but nothing
blocks the attempt.(ip, email) with a short backoff window
after N failures (e.g. 5 in 60 s → 30 s pause; 20 in 10 min → block IP
for 1 h).auth_throttle) so a process restart
doesn't reset it.X-Forwarded-For ignored; audit IP is wrong behind proxiesa2e77ea — paired with R01-N05. The new
TrustedProxies::clientIp() walks X-Forwarded-For rightmost-to-
leftmost (with REMOTE_ADDR logically appended) and returns the first
hop that is not in any configured CIDR. When the immediate peer is not
itself a trusted proxy the header is ignored entirely — so a hostile
off-net client cannot forge an audit IP. Request::ip() now goes
through this helper, which means the audit pipeline (AuditLogger::
audit()) and the R01-N06 throttle bucket both pick up the real client
address with no further changes. IPv4-with-port (1.2.3.4:51234) and
bracketed-IPv6 ([::1]:443) hops are normalised by stripping the port.
Configured via the new TRUSTED_PROXIES= env var (comma-separated
CIDRs; bare IPs treated as /32 or /128); blank by default so no
trust is granted to anyone unless the operator opts in. Documented in
admin-manual §3.5 with an Nginx snippet showing the required
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for line.
Pinned by tests/Http/TrustedProxiesTest.php (14 cases): direct-
client return, single trusted hop, multi-hop walk, all-trusted edge
case, untrusted-remote-ignores-XFF, port stripping, IPv6 CIDR, host-
mask shorthand, bad-CIDR typo (must not widen trust), env parser
comma + whitespace, env blank → no trust, plus three Request-level
wiring checks. Tests: 202 / 533 (was 184 / 502).src/Http/Request.php::ip() lines 103-107.Request::ip() returns $_SERVER['REMOTE_ADDR'] only. When
deployed behind any reverse proxy / load balancer, every audit row will
record the proxy's IP, not the user's.X-Forwarded-For. Default empty (no trust). Document in admin
manual.bc745cd — SessionGuard gained a 30-minute idle
window (IDLE_TIMEOUT_SECONDS = 1800) enforced inside start(). New
pure-static helpers isIdleExpired($lastActive, $now) and
expireIdleSession(array &$session, $now) carry the policy: on every
start(), if user_id is set and $now - last_active >= 1800, the
authenticated keys (user_id, login_at, last_active,
csrf_token) are dropped and session_regenerate_id(true) rotates
the id — the next gate sees an anonymous session and redirects to
/auth/login. Foreign session state (the OIDC library's
state/nonce/PKCE) is preserved so an in-flight bounce to Entra is
not killed by a stale idle clock. login() now stamps
last_active = time() and unset()s csrf_token, so a token a
pre-login attacker may have captured from the public homepage
form cannot be replayed against the now-authenticated session
(the next csrfToken() call mints a fresh
bin2hex(random_bytes(32))). Boundary is >= so a session
exactly 1800s idle is expired. New tests/Auth/SessionGuardTest.php
(9 cases) pins the constant, the boundary semantics
(0 / 1s / 1799s / 1800s / 2h), the anonymous-session no-op, the
last_active-missing seed-on-first-hit, the auth-key drop on
idle with foreign-key survival, and the non-int user_id
defence. Tests: 211 / 562 (was 202 / 533).src/Auth/SessionGuard.php lines 27-60, 109-116.session.gc_maxlifetime = 28800 (8h) and the session cookie has
lifetime=0 (browser-session). Once authenticated, a user stays signed in
until the browser closes or 8h passes since last GC tick. The CSRF token
is generated once and reused for the lifetime of the session.$_SESSION['login_at'] (already set!) and an idle timestamp; on
each request, if now - last_active > 30 min, force re-auth.login() (after session_regenerate_id) so
pre-login tokens can't be replayed.SameSite=Lax, not StrictLax is required for the OIDC return.
The original "Suggested fix" understated the cost: when Entra redirects
the user back to /auth/callback it is a cross-site initiated top-
level navigation (initiator origin is login.microsoftonline.com), so a
SameSite=Strict session cookie would NOT be sent. The OIDC library
(jumbojett/openid-connect-php) reads the state and PKCE
code_verifier it stashed in $_SESSION BEFORE the bounce — without
the cookie those lookups fail and the callback errors out. We therefore
keep Lax (cookie sent on top-level cross-site GETs only — never on
cross-site sub-resource requests, image GETs, or any non-GET method).
The real CSRF defence is SessionGuard::verifyCsrf() (per-session
token, header or _csrf form field, hash_equals()); the OIDC
callback adds the library's state/PKCE check on top. A "split-cookie"
approach (Lax cookie just for the OIDC handshake, Strict for the main
session) was considered and rejected as not worth the complexity:
defence-in-depth gain is small and we'd have to teach two cookies to
every place that rotates session ids.src/Auth/SessionGuard.php lines 41-77.Lax allows the cookie on top-level cross-site GETs (a click
from a phishing page).c1dbfc1 — all three MAX(sort_order) lookups
now use prepared statements with ? placeholders instead of inlining
the route-cast int into the SQL. TaskRepository::create (was
line 60) and TaskRepository::moveToSprint (was line 100) prepare
'SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE sprint_id = ?'
and bind $sprintId / $destSprintId. SprintWorkerRepository::add
(was line 56) does the same against sprint_workers. The contract is
now explicit at the SQL boundary — a future caller passing an
unvalidated string is still type-safe because the value travels as a
bound parameter, never as a string fragment of the query. No new
tests: the fix is a mechanical refactor, the existing repo tests
exercise these paths under tests/Cascade and tests/Controllers.
No SPEC behaviour change.src/Repositories/TaskRepository.php line 60 (MAX(sort_order) FROM
tasks WHERE sprint_id = ' . $sprintId) and line 100 (moveToSprint).src/Repositories/SprintWorkerRepository.php line 56
(MAX(sort_order) FROM sprint_workers WHERE sprint_id = ' . $sprintId).src/Controllers/SprintController.php lines 1029-1033 — uses
placeholders, OK.? placeholders; never
concatenate. Mechanical change, low risk.AuditRepository::distinctColumn interpolates a column name4ae1817 — distinctColumn() now opens with an
in_array($col, ['action', 'entity_type'], true) whitelist; anything
else throws \InvalidArgumentException before the interpolated SQL is
prepared. The phpdoc was also tightened to the literal-string union so
static analysers carry the contract. New
tests/Repositories/AuditRepositoryTest.php (4 cases) covers the happy
paths for both supported columns and two reflection-based regression
guards (rejects an unsupported known column like user_email, rejects
a SQL-injection attempt). Tests: 163 / 417 (was 159 / 413).src/Repositories/AuditRepository.php lines 109-122."SELECT DISTINCT {$col} FROM audit_log ORDER BY {$col} ASC"
interpolates $col. Today the only callers are distinctActions() and
distinctEntityTypes() with literal strings, but the method is private
with no enforced whitelist.if (!in_array($col, ['action','entity_type'], true)) throw … at the top
of distinctColumn. Cheap, ends the foot-gun.from_date/to_date filters not validated1b28469 — AuditController::index now routes
both raw inputs through a pure static validateDateFilters($from, $to)
that strict-parses Y-m-d (createFromFormat + round-trip equality,
same pattern as SprintController::isIsoDate) and splits the output
into from / to (empty string when the input fails to parse so the
filter is dropped instead of poisoning the query) and an errors map
keyed by field name. The repo is fed the validated copy; the view
echoes the user's raw input back into the date fields so they can fix
the typo, tints the offending input amber, and shows an inline
"Use the format YYYY-MM-DD" notice plus a banner. No session-flash
plumbing — the form is GET-driven and the response is the form, so
errors travel with the rendered page. New
tests/Controllers/AuditControllerTest.php (16 cases) pins the
matrix: empty / valid / one-side-bad / both-bad, lenient rollovers
caught by the round-trip check (2025-02-29, 2026-13-01,
2026-02-30, 2026-1-1), whitespace rejected, leap-year preserved
verbatim, an injection-shaped string rejected. Suite: 227 / 590 (was
211 / 562).src/Repositories/AuditRepository.php lines 149-156.src/Controllers/AuditController.php lines 39-49."{$date}T00:00:00Z" and
bound. Any garbage string just sorts lexically against the ISO-8601
occurred_at column — silently producing empty result sets without an
error. Not an injection (parameters are bound), but a bad UX and a way to
hide events from a casual auditor (e.g., admin types 2024/01/01 instead
of 2024-01-01 and "sees" no rows).Y-m-d server-side; on parse failure, drop
the filter and flash an error. Same DateTimeImmutable::createFromFormat
pattern already used in SprintController::isIsoDate.d7dbfb5 — new App\Http\FatalErrorHandler
registers BOTH set_exception_handler (catches uncaught throwables
escaping Router::dispatch()) and register_shutdown_function
(filtered on a fatal mask: E_ERROR | E_PARSE | E_CORE_ERROR |
E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR, so suppressed
warnings/notices don't trigger). On fire, the handler drains every
open output buffer (so a half-rendered Twig template can't leak in
front of the 500 page), then emits a minimal 500 — Server error
HTML body with the SAME security headers a normal response carries
— nosniff, X-Frame-Options DENY, Referrer-Policy, the strict CSP,
and HSTS when the resolved scheme is HTTPS. Production hides the
throwable class + message; non-production echoes them (HTML-escaped)
for debugging. To prevent the happy/error paths from drifting on
CSP edits, public/index.php now sources its security headers from
FatalErrorHandler::securityHeaders($isHttps) — single source of
truth. The handler is registered TWICE: once with isHttps=false
immediately after autoload (so a fatal during migrations or service
wiring still produces a clean 500), and again with the resolved
HTTPS flag right before Router::dispatch(). New
tests/Http/FatalErrorHandlerTest.php (7 cases) pins the contract
— production hides detail, dev surfaces it (HTML-escaped against
XSS payloads), HSTS rides along on HTTPS only, headers are skipped
cleanly when headers_sent() returns true (mid-flight fatal),
the buffer drain runs exactly once and BEFORE any header / body
write, and securityHeaders($isHttps) matches the emitted set.
Tests: 235 / 624 (was 227 / 590).public/index.php lines 38, 248-254.ob_start() is called at the top, but the flush is in the
happy path. On a fatal error mid-request (uncaught throwable that escapes
dispatch(), OOM, etc.), PHP's output_buffering may flush whatever was
in the buffer to the response — without the security headers added at
lines 240-243.var_dump
could land in the buffer when display_errors=1. In production with
display_errors=0 the risk is small, but the headers (CSP, X-Frame, etc.)
still won't be applied to whatever does land.set_exception_handler and a register_shutdown_function
that discards the buffer (ob_clean()) and emits a minimal 500 page
with the security headers.ob_start() and rely on Response::send() to be the single
output point; use headers_sent() checks before writing anything.$_SESSIONd7dbfb5 — went with the cap-only path.
ImportController::MAX_SESSION_PAYLOAD_BYTES = 2 * 1024 * 1024 (2 MiB)
bounds the JSON-encoded preview blob; upload() encodes the
ParsedSheet[]::toArray() result once, and a payload past the cap
is rejected with ?error=too_large_payload (new entry in
views/sprints/import_upload.twig's message map). The encoded form
is reused as the size estimate via the new pure-static
encodedPayloadBytes(array $sheetsArr): int helper — same encoding
flags as AuditLogger::encodeJson (JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES). The session entry also gains a
payload_bytes field so the abandoned-token audit row has a real
number rather than unknown. The "stream-parse + temp file"
alternative was considered and rejected — the cap is enough for
the threat model (admin-only upload, 5 MB raw cap already in place,
www-data-only session files), and the temp-file approach adds
a disk-hygiene problem the cap dodges entirely.pruneSessionImports() (called
on every fresh upload, drops aged-out tokens) and
loadSessionEntry() (called by preview() / commit(), drops a
token whose TTL elapsed mid-flow). The row is action=
IMPORT_PREVIEW_ABANDONED, entity_type=import_token, entity_id=NULL
with after_json carrying {file_name, sheet_count, payload_bytes,
age_seconds, created_at} (UTC ISO-8601). The pure-static
abandonedAuditPayload(array $entry, int $now): array builder is
guarded against malformed entries (created_at missing → age
clamps to 0 instead of leaking the unix-epoch offset; clock skew
giving a "future" entry → age also clamps to 0). The
ImportController constructor gained an AuditLogger dependency;
wiring updated in public/index.php.tests/Controllers/ImportControllerTest.php grew from
4 to 12 cases. New cases pin: encodedPayloadBytes([]) is 2 (just
[]); the helper agrees with a hand-rolled json_encode + strlen
on a real-shaped payload; the cap constant is exactly 2 MiB
(drift fence so a future bump must update REVIEW_01.md +
the upload form's error message); a payload of cap+1 length is
measurable as larger than the cap; the abandoned-audit payload
has the right keys + types + a strict ISO timestamp; missing
fields produce safe defaults; clock skew → age 0. Suite:
242 / 673 (was 227 / 590).src/Controllers/ImportController.php lines 102-115 (write),
244-261 (read). Session storage path is on disk under
/var/www/data/sessions/.fromArray() reconstruction). And on disk these
files are readable by www-data only — but they linger 30 min by design.IMPORT_PREVIEW_ABANDONED audit row when a token
expires unused.target="_blank" task URL anchor uses rel="noopener" onlyd16bff4 — the user-controlled task link in
views/sprints/_task_list.twig now emits rel="noopener noreferrer",
so the Referer header no longer leaks the originating
/sprints/{id} URL when an admin clicks an attacker-set t.url.
Scope intentionally narrow: the /sprints/{id}/present anchor in
views/sprints/show.twig was left as rel="noopener" because it is
same-origin and the Referer leak the finding describes does not
apply.views/sprints/_task_list.twig line 213.rel="noopener" blocks window.opener access, but the
Referer header still leaks the origin URL (/sprints/{id}) to the
external link target.t.url) reveals that
internal sprint exists.rel="noopener noreferrer".ef9b9b8 — went with the doc + tooling path, no
CI hook (this repo has no CI today; adding GitHub Actions would
introduce a new external dependency the operator hasn't asked for).
New bin/audit.sh wraps composer audit --locked --no-interaction
inside sprint_planer_web-app:latest, so the audit reflects the
exact dependency tree the live container ships and not whatever the
host PHP happens to have. Honours SPRINT_PLANER_IMAGE for
non-default tags. Refuses cleanly when docker is missing or the
image hasn't been built. Today's baseline: "No security
vulnerability advisories found." doc/admin-manual.md §5.5 grew a
"Composer dependency cadence" block — recommended rebuild rhythm
(after every git pull, monthly minimum), the auditor invocation,
and a copy-pasteable weekly-cron snippet that pipes a non-zero exit
to mail. PhpSpreadsheet itself stays at the ^3.4 caret range —
minor upgrades will land on each docker compose build --no-cache
with no manifest change required. No new tests (script is operator
tooling, not application code).state/PKCE storage in session — verify lifetimevendor/jumbojett/openid-connect-php/src/OpenIDConnectClient.php. The
library uses fixed session keys openid_connect_state,
openid_connect_nonce, openid_connect_code_verifier (lines
1789-1866); the methods setSessionKey() / getSessionKey() /
unsetSessionKey() are protected so a subclass could namespace
them, but the namespace would then have to round-trip via a separate
short-lived cookie (the OIDC state parameter is generated and read
by the library itself, not by us). The realistic failure mode is
small: the second tab clobbers the first tab's state, the first
tab's callback hits the if ($_REQUEST['state'] !== $this->getState())
{ throw 'Unable to determine state' } guard at line 322, the user
is bounced to /?auth_error=1, and they retry. That guard is the
correct OIDC state-mismatch behaviour — it's what stops a CSRF on
the OIDC callback. The "fix" (subclass + transient flow-id cookie,
~80 LoC + tests) trades real complexity for a one-line UX nit. Not
worth it for a single-admin tool. doc/admin-manual.md §5.6
"Tabbed sign-in note" documents the rule for operators: complete
one sign-in at a time. No code change.src/Auth/OidcClient.php + src/Controllers/AuthController.php
callback.jumbojett/openid-connect-php stores its state and PKCE
code_verifier in $_SESSION. SessionGuard::login() calls
session_regenerate_id(true) after the callback completes — but the
state/verifier are written before the redirect to Entra and read after the
bounce back. If two browser tabs initiate auth flows simultaneously, the
second overwrites the first's state. Not a direct vuln but a usability
hazard.a0b717e — new App\Auth\OidcClaims::resolveEmail
($claims, $oid) is the single decision point for the audit-trail
email. The preferred_username fallback is removed entirely (the
actual hazard the finding flagged: user-controlled in some Entra
tenants). claims.email is honoured when present, EXCEPT when the
issuer explicitly marks it email_verified === false — that case
falls back to the documented immutable label entra:<oid> so the
forensic trail still has a stable, non-spoofable actor. A missing
email_verified flag is treated as "no negative signal" because
Entra v2.0 work/school tokens do not emit it (the email there comes
from the directory and is server-controlled); requiring strict
=== true would flip every existing audit row to entra:<oid>
which is an unnecessary regression for the threat model. The
oid-or-sub check moved up so a missing identifier rejects the
callback BEFORE the email resolver runs (the resolver assumes a
non-empty oid for its fallback). Identity is still keyed by oid
/ sub and is unaffected. New tests/Auth/OidcClaimsTest.php (10
cases) pins the matrix: verified-true / verified-absent /
verified-false / preferred-username-fallback-rejected / blank-email
/ whitespace-trim / no-claims-at-all / non-bool-truthy
email_verified / non-scalar email defence. Suite: 252 / 683 (was
242 / 673).src/Controllers/AuthController.php lines 75-77.email = $claims->email ?? $claims->preferred_username. In Entra,
preferred_username is not guaranteed to be a verified email; in some
tenant configurations it can be controlled by the user. We don't check
email_verified either.user_email in
audit_log), a user can spoof their actor email. The entra_oid (or
sub) is the immutable identifier and is correctly used as the lookup
key — so the user identity is safe, but the audit trail's display
isn't.claims.email only when claims.email_verified === true.entra:<oid>.report-uri / report-tof59f368 — went with report-uri only (CSP
Level 1/2 directive, still honoured by every current browser; CSP3
report-to would have needed a Reporting-Endpoints response
header on every page and a second JSON parser for the
application/reports+json shape, neither cheap for the marginal
gain). App\Http\FatalErrorHandler::CSP gained
report-uri /csp-report. New public route POST /csp-report
routes to App\Controllers\CspReportController::report, which
body-caps at MAX_BODY_BYTES = 16 * 1024 (real CSP reports are
well under 1 KiB; the cap is generous enough to never reject
legit reports, tight enough that a hostile client can't pump
megabytes of garbage into audit_log). The legacy
{"csp-report": {...}} envelope is unwrapped via the pure-static
extractReport(string): ?array helper; bare {...} shapes are
also accepted; top-level lists / scalars / non-JSON return null.
The endpoint is anonymous: no session, no CSRF — a browser fires
reports without session context most of the time, and a forged
report only costs one audit row (bounded by the cap). Always
responds 204 (no leaks about whether the report was accepted or
ignored). Audit row shape: action=CSP_VIOLATION,
entity_type=csp_violation, entity_id=NULL,
before_json=NULL, after_json=<inner report>, user_id=NULL,
user_email=NULL, ip_address=<client>, user_agent=<browser>.
AuditRepository::distinctEntityTypes() already powers the
/audit view's filter dropdown, so csp_violation shows up
there automatically. FatalErrorHandlerTest CSP-line assertion
grew a report-uri check (drift fence). New
tests/Controllers/CspReportControllerTest.php (10 cases) pins
the 204-always rule, the body cap, the JSON-object-only contract,
the envelope unwrap, and the audit-row shape end-to-end against
an in-memory DB. Tests: 266 / 721 (was 252 / 683).public/index.php lines 227-237.report-uri /csp-report (or report-to)
directive; route it to a thin endpoint that drops the JSON in the audit
log under entity_type='csp_violation'. Cheap; high signal.Response::redirect() accepts arbitrary location stringsf1aa924 — went with both bullet points
from the original Suggested-fix list. Response::redirect($location)
now refuses anything that is not a single / followed by something
other than /, and also refuses CR/LF/NUL (header injection).
Protocol-relative URLs (//evil.example.com/x) — the dangerous
shape that looks like a path — are caught by the second test
(without it str_starts_with($location, '/') would happily wave
them through). The HTTP→HTTPS canonical redirect in
public/index.php (the one place that needs to leave the origin)
moves to the new Response::external($url, $status) helper, which
restricts the scheme to http/https and requires a non-empty
host so a stray javascript: / data: payload cannot wind up in
the Location: header. Both helpers throw
\InvalidArgumentException on misuse — loud in dev/test, harmless
under the existing FatalErrorHandler safety net in production.
Pure-static isPathOnly() and isExternalUrl() back the contract
for direct unit tests. All 56 existing callers were audited:
every one passes a /-prefixed literal or a '/'-prefixed
template, and dynamic suffixes are integer IDs / tokens / urlencode()
output, so none regress under the new guard. New
tests/Http/ResponseTest.php (15 cases, 36 assertions). Tests:
302 / 791 (was 266 / 721).src/Http/Response.php lines 54-57.?next= from user input and redirect off-origin.assert() or runtime check that the value starts with / or matches
APP_BASE_URL.Response::external($url) for the rare cases that
actually need to leave the origin.00bcf73 — went with the audit's two-test
recommendation. New tests/Http/TwigAutoescapeTest.php (3 cases,
23 assertions). Behaviour pin: renders a known XSS payload
through a synthetic Twig template using the same View env the
controllers use and asserts <script> does NOT survive the
render (<script> does), so a future flip of View's
autoescape setting from 'html' to anything else fails loudly.
A second case pins attribute-context double-quote escaping
(title="…" → "). Static guard: walks every .twig
file under views/ and fails if any line carries
|\s*(raw|safe) or {%\s*autoescape. Verified end-to-end by
temporarily appending {{ "x"|raw }} to home.twig — the test
reported the exact path:line of the offence with a remediation
hint. No production code changed; the guards live entirely in
the test suite. Today's scan: 0 offences across all .twig
files. Tests: 305 / 814 (was 302 / 791). The audit's optional
CI grep step is subsumed by this PHPUnit case (we already run
the suite on every change), so no separate grep stanza needed.src/Http/View.php line 30 (autoescape => 'html'); various
views.autoescape: html. Spot-checks confirm no |raw, |safe, or
unsafe attr filters are used (one place uses |json_encode|e('html_attr')
for the data-sprint-choices payload — correct usage).|raw on a
user field opens stored XSS in a place where it's particularly painful
(the audit viewer renders attacker-controlled content).autoescape config in a CI test that loads View and asserts the
flag.grep -rn '|raw' views/ must
return nothing (or only allow-listed lines).43b2fc9 — went with both bullet points from
the audit's Suggested-fix list. Migrations are now applied by the
new bin/migrate.php CLI, called from bin/docker-entrypoint.sh
before exec apache2-foreground, so a failed migration aborts
container start (docker logs carries the stderr) instead of
leaking into the request path. The web request path only calls
the new pure-read Migrator::pendingFiles() (which diffs files on
disk against schema_version without applying SQL) and emits
503 Service Unavailable + Retry-After: 30 + a structured
error_log line when anything is pending. Bare-metal deploys that
skip Docker run the CLI manually as part of the release procedure;
doc/admin-manual.md §5.5 documents both. The 503 path is
intentionally loud — the alternative (auto-migrating in a request
that may then crash mid-DDL) is exactly the partial-schema failure
mode the finding flagged. New tests/Db/MigratorTest.php (6
cases) pins pendingFiles() against the real migrations/
folder, virgin-DB / post-migrate / drift-after-deploy paths, the
ignore-non-NNN_*.sql rule, and the schema_version-creation
side-effect. Tests: 311 / 823 (was 305 / 814).public/index.php lines 75-104 (check + 503).src/Db/Migrator.php — pendingFiles() added; migrate()
unchanged.bin/migrate.php (new), bin/docker-entrypoint.sh (new).Dockerfile — ENTRYPOINT + CMD.pdo->exec($sql) (multi-
statement). On a fresh deploy, the first request after the new code
ships did the migration; if that request crashed mid-migration (timeout,
OOM), partial state could persist. SQLite + the migrator's BEGIN..COMMIT
protects most cases, but ALTER TABLE … ADD COLUMN is a DDL that's
conditionally inside a tx — see SQLite caveats.php bin/migrate.php before starting Apache).users table not deleted; emails not redacted on demote22a3840 — went with the audit's
Suggested-fix plan exactly: nullable users.tombstoned_at column
(migration 006) plus a soft erasure flow surfaced as an explicit
admin action. User::publicEmail() / publicDisplayName() return
the documented (former user) label when tombstoned; live views
(Users page, anywhere a User object surfaces) consult those
helpers, while email / displayName on the snapshot — and
therefore everything that flows into the audit_log.user_email
column — keep the historical value verbatim. The trail is the
authoritative record; erasure regulations permit retention for
legal / security purposes (documented in admin-manual §5.1.1
with that stance spelled out for the operator). New
tombstone() controller action routed at
POST /users/{id}/tombstone with a hidden action
(tombstone / restore) field; pure-static tombstoneGuardrail()
rejects self-tombstone, blocks tombstoning the last remaining
admin (because tombstone forces is_admin=0), and short-circuits
no-ops. A successful sign-in (OIDC or local-admin / forceAdmin)
clears tombstoned_at — the marker is a display redaction, not an
access control; if the org actually wants to revoke access the
OIDC tenant is the right place. Audit action labels: TOMBSTONE
and RESTORE on entity_type=user, picked up automatically by the
/audit filter dropdown via distinctActions(). Tests:
325 / 853 (was 311 / 823). UserRepositoryTest grew by five cases
(auto-untombstone on both sign-in paths, force-demote, restore-
doesn't-promote, public-label assertion). UserControllerTest grew
by eight (tombstoneGuardrail matrix).migrations/006_users_tombstoned.sql (new column).src/Domain/User.php (snapshot field + display helpers).src/Repositories/UserRepository.php (setTombstoned() + auto-
clear in upsertFromOidc).src/Controllers/UserController.php lines 16-25 (header
rewritten) + new tombstone() action and
tombstoneGuardrail().views/users/index.twig, views/audit/index.twig (display).doc/admin-manual.md §5.1.1, SPEC.md §7.email and display_name in the users table forever; the audit
log's user_email column also retained it. There was no redact / tombstone
flow.users.tombstoned_at column; when set, hide name/email from UI
and replace with (former user). Audit rows keep the old email
verbatim — the audit log is the authoritative trail and erasure laws
typically permit retention for legal/security purposes.doc/admin-manual.md.821122d — went with both bullet points
from the audit's Suggested-fix list. Request::MAX_BODY_BYTES =
1 MiB; Request::fromGlobals() now checks Content-Length
before reading php://input, so an oversized body is never
loaded into PHP memory in the first place (with a post-read
length check as defence-in-depth for clients that under-report
or omit the header). The decision rides through to the new
bodyTooLarge constructor flag, which public/index.php
consults BEFORE the HTTPS redirect — wasting a TLS round trip
on something we'd reject anyway is a worse experience than a
fast 413. The 413 ships as the standard
{ok: false, error: { code: 'request_too_large', … }}
envelope; HTML form clients (which would already be in
pathological territory at >1 MiB) see the JSON body in the
browser, an acceptable trade-off vs. plumbing a separate
HTML branch. Per-batch cap: MAX_BATCH_ITEMS = 5000 on both
SprintController and TaskController. The four list-bodied
endpoints — updateWeekCells, updateAssignments,
updateAssignmentsStatus, and tasks reorder — reject past
the cap with too_many_items 413. Real workloads sit
comfortably under: a sprint has ≤26 weeks (schema cap) and a
realistic worker count, so the wall is well above any genuine
UI flow but dodges the "1 MiB body packed with tens of
thousands of tiny cells" path that would still keep one
transaction long-running. New RequestTest cases (4) pin the
constant, the constructor flag default + wiring, and the
json()-returns-null contract when bodyTooLarge is set.
SprintControllerTest + new TaskControllerTest pin
MAX_BATCH_ITEMS = 5000 and the cross-controller drift fence
(the two caps must stay in lockstep). Tests: 331 / 860 (was
325 / 853).src/Http/Request.php — MAX_BODY_BYTES, bodyTooLarge
flag, fromGlobals() short-circuit.public/index.php — pre-dispatch 413.src/Controllers/SprintController.php,
src/Controllers/TaskController.php — MAX_BATCH_ITEMS +
per-endpoint guards.file_get_contents('php://input') read whatever PHP's
post_max_size allowed (default 8 MB). json_decode with depth=64 is
reasonable, but a 7 MB JSON list with 1M entries was parsed before
any controller-level cap.Content-Length first; refuse >1 MB with 413./sprints/{id}/week-cells).X-Permitted-Cross-Domain-Policies headerf6ce13f —
FatalErrorHandler::securityHeaders() (the single source of truth
shared by the happy path and the 500 fallback per R01-N13) now emits
X-Permitted-Cross-Domain-Policies: none unconditionally — on HTTP
and HTTPS alike, unlike HSTS. The drift fence in
FatalErrorHandlerTest grew two assertions: the production-500 emit
carries the header, and the helper map carries it on both
securityHeaders(true) and securityHeaders(false). The optional
Permissions-Policy directive from the Suggested-fix list was left
out — the app does not use any of the gated browser features
(geolocation, camera, mic, USB, …) so the directive would be a no-op
with maintenance cost; revisit if a future feature legitimately
needs one of those APIs and we want an explicit allow-list. Tests:
333 / 868 (was 331 / 860).public/index.php lines 240-243.X-Permitted-Cross-Domain-Policies: none.
Optional: Permissions-Policy to disable geolocation / camera / etc.home.twig "deleted" flash relies on query string truthiness1f11117 — went with the audit's
Suggested-fix exactly: SprintController::delete stashes the
deleted sprint's name in $_SESSION['flash_deleted_sprint_name']
(matching the existing flash_* convention from
ImportController) and redirects to a bare /. The home handler
in public/index.php reads the key — SessionGuard::currentUser
has already started the session by that point — and unset()s it
on render, so a manual page refresh cannot re-show the chip and a
hand-crafted URL cannot trigger it at all. The view's {% if
deletedSprintName|default('') != '' %} guard is unchanged; only
the data source moved. New TwigViewTest cases (2): chip renders
when the flash is set (and the name is HTML-escaped, drift fence
for R01-N21's autoescape pin), chip stays absent when the flash
is empty. Tests: 333 / 868 (was 331 / 860).views/home.twig lines 11-15; public/index.php line 150;
src/Controllers/SprintController.php::delete line 1114./?deleted=<urlencoded
name>. Anyone can manually craft a URL with ?deleted=foo and see the
green "Sprint foo was deleted" chip — no actual deletion happened. Twig
auto-escape protects against XSS, so this is purely a UX/spoofing nit.$_SESSION['flash_*'],
the import controller already does this), unset on render.32d03fc — went with a backgrounded loop in
bin/docker-entrypoint.sh rather than installing cron (cron would
be a new external dependency for a 4-line shell loop). After
migrations succeed, the entrypoint spawns a child process that
sleeps ${SESSION_GC_INTERVAL_SECONDS} (default 3600 = 1h), then
runs find ${SESSION_PATH} -mindepth 1 -type f -mmin
+${SESSION_GC_MAX_AGE_MINUTES} -delete (default 480 minutes = 8h,
matching gc_maxlifetime on SessionGuard::start()). The loop is
a child of the entrypoint's PID 1, so docker stop propagates
SIGTERM and reaps it together with Apache; no orphaned process on
container restart. find is a coreutils binary already in the
php:8.3-apache base image, so no new package install. Both knobs
are env-var overridable
(SESSION_GC_MAX_AGE_MINUTES,SESSION_GC_INTERVAL_SECONDS) for
operators who want a tighter sweep on a public deployment.
doc/admin-manual.md §5.7 documents the loop, the env vars, and a
copy-pasteable system-cron snippet for bare-metal deployments that
don't run the container's entrypoint. No new tests — the change is
shell-script + docs, exercised by docker compose up. Existing
PHPUnit suite still 333 / 868.Dockerfile line 57; src/Auth/SessionGuard.php lines 33-39.gc_maxlifetime. Not a session-validity issue
(the session contains login_at + cookie expiry, see R01-N08), but a
storage hygiene one.find /var/www/data/sessions -mmin +480 -delete
in the container, or accept it as low-impact.SettingsController::update builds $changed[] but never uses itsrc/Controllers/SettingsController.php lines 95-127.$changed[] is appended to but the variable is never read.
Audit happens on a per-key basis already; the local accumulator is dead.?updated=N instead of always ?updated=1).Migrator::apply reads file via file_get_contents without size capsrc/Db/Migrator.php lines 101-125.import_upload.twig reveals colour-mapping rules to anyone with the linkviews/sprints/import_upload.twig lines 42-52.SessionGuard::requireAdmin) so this is
not exposed publicly. Listed for completeness.home.twig link to /healthz is a tiny info-disclosure7fd849b — folded into the R01-N02 fix; the
<a href="/healthz"> line lives inside the now admin-only Runtime
panel, so anonymous users no longer see it.views/home.twig line 135./healthz. Liveness probes
are normally undocumented to discourage scanning; the route returns
literal "ok" anyway, so the disclosure cost is tiny. Combined with R01-N02
the fix is the same: gate the runtime panel.views/sprints/settings.twig line 181 (data-confirm-name=…).data- attribute so the JS can compare against typed input.
An attacker who can read the page already has the name from the title
bar — but the JS guard is bypassable trivially. The server-side check at
SprintController::delete lines 1001-1004 is the real defence.data-csrf attribute reads CSRF token in HTMLviews/sprints/show.twig line 9, present.twig line 7,
settings.twig line 9.data-csrf attribute so
vanilla JS (fetch calls) can attach it as X-CSRF-Token. This is the
standard pattern; not a leak (the token is per-session and JS-readable
by design).Migrator discovery regex permits unintended filenamessrc/Db/Migrator.php line 91.^(\d{3,})_[A-Za-z0-9_\-]+\.sql$ — accepts e.g.
001_evil_inject.sql if dropped into migrations/. That is, a bad actor
with write access to migrations/ can run arbitrary SQL on next request.
This is an "if attacker has filesystem access, game over" scenario, but
worth noting that the migration runner does no signature / allow-list
check.gh-pr-deploys workflow that lets non-admins land
files in migrations/), reconsider.These are not findings; recorded so we don't re-investigate later:
?
placeholders except the integer-cast cases noted in R01-N10.verifyCsrf (or
the JSON variant). The audit hits 18/18 mutations per Phase 7.html context); no |raw usage in
views/.script-src 'self', style-src 'self', no
unsafe-eval / unsafe-inline); Alpine CSP build, vendored htmx /
Sortable. frame-ancestors 'none'. form-action allows
login.microsoftonline.com (needed for OIDC).X-Frame-Options, X-Content-Type-Options, Referrer-Policy
all set per request.PRAGMA foreign_keys = ON set on every PDO
connection.BEGIN…
COMMIT and rolls back on throw.sprint→tasks→task_assignments, sprint→sprint_workers→
sprint_worker_days, sprint→sprint_workers→task_assignments,
sprint→sprint_weeks→sprint_worker_days, plus Phase 22 tasks(linked_
task_id)→SET NULL) snapshot before the parent delete fires.hash_equals.bin2hex(random_bytes(32)) — 256 bits.HttpOnly, SameSite=Lax, Secure (when behind
HTTPS, see R01-N05). No need to mark non-essential cookies.is_uploaded_file + 5 MB cap. Reasonable.{ok, data|error} per spec §7; consistent across
endpoints.CapacityCalculator::isHalfStep and the
per-cell range checks are tight.eval, no exec, no system: confirmed by grep.A reasonable cadence (do not treat as binding):
7fd849b. Also
closes R01-N31.SESSION_SECRET doc cleanup)296883c.rel="noopener noreferrer")d16bff4.4ae1817.857df15
(hash-only, no plaintext fallback per operator decision).e295432.f565c86.a2e77ea.bc745cd.c1dbfc1.1b28469.d7dbfb5.d7dbfb5.a0b717e.ef9b9b8.report-uri + audit endpoint)f59f368.f1aa924.00bcf73.f6ce13f.1f11117.32d03fc.Each fix should ship as its own commit per SPEC.md §14, with a follow-up SPEC update if behaviour or config surface changes.