Files
summercms/.planning/phases/11-jobs-realtime-and-search-infrastructure/11-SECURITY-REVIEW.md
2026-09-30 22:24:12 +02:00

25 KiB

phase, reviewed, reviewer, threats_open, gate, removal_harness
phase reviewed reviewer threats_open gate removal_harness
11 2026-09-30 gsd-executor, plan 11-07 (code-and-test review of plans 11-01 to 11-07); T-11-31, T-11-32, RC-14 and RC-15 added in the 11-08 close-out/review 0 scripts/check-phase11.sh --all scripts/check-phase11.sh --removal

Phase 11 Security Review

This is a fresh code-and-test review of every threat in the registers of Plans 11-01 to 11-08 (T-11-01 to T-11-32 and T-11-SC; T-11-31 and T-11-32 come from gap plan 11-08). Severity and disposition are copied from the originating plan. A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. scripts/check-phase11.sh --removal does this for every high mitigated threat: it applies an anchor-exact mutation that removes the protection, runs the named test, requires it to fail on an assertion (a build failure does not count), and restores the file byte for byte, checked with cmp. The results are recorded under "Removal checks". The accepted threat keeps its rationale verbatim from its originating plan.

The review found three defects in Phase 11 code, all fixed in plan 11-07 with a failing-when-broken test:

  • lighthouse broadcast callbacks ran after GORM's own commit for a single-statement write, so the broadcast job was enqueued outside the write transaction (T-11-05; deferred from 11-05);
  • lagoon handed after-commit callbacks a handle that carried the written model's statement (deferred from 11-05);
  • beachcomber and lighthouse released their savepoint whenever the inner function reported no error, so a read failure that a Gate or channel function swallowed left the caller's Postgres transaction aborted (T-11-20, T-11-26).

Gap plan 11-08 closed verifier gap CR-01: lagoon.AfterCommit ran search sync immediately inside a plain GORM transaction, and the Cabana admin and SaveAlbum write paths used plain transactions, so Typesense received uncommitted, pre-pivot or rolled-back Album state (T-11-31, T-11-32).

Commands run from summercms.go. ../fonoteka.go tests run inside that repository. Gate stages are modes of scripts/check-phase11.sh.

Threat Category Component Severity Disposition Production mitigation Test or gate stage Observed result Residual risk
T-11-01 Spoofing subscribe proxy high mitigate lighthouse/centrifugo/handlers.go ProxyHandler compares X-Centrifugo-Secret with subtle.ConstantTimeCompare before reading the body; an empty configured proxy_secret denies everything TestProxy (missing, wrong and prefix secrets, empty_configured_secret_denies_everything, no secret in logs), fonoteka TestRealtimeSubscribeProxy, TestRealtimeSubscribeProxyWithoutSecretDenies; stage --named pass; removal check RC-01 fails TestProxy Anyone who can read the Centrifugo config holds the secret; rotate it with the Centrifugo deployment
T-11-02 Elevation of Privilege channel parsing and authorizers high mitigate lighthouse/channel.go ParseChannel (presence:presence: and over three segments give the empty namespace), ChannelID/PHPInt (PHP 8.5 (int)), byte-exact Registry.Get; fonoteka classes/ws CollectionAuthorizer (user exists, owner or editor, kind = collection) and WishlistAuthorizer (subscriber or household peer, kind = wishlist), both reading the database on every subscribe TestParseChannel, TestChannelIDMatchesPHP (44 php -r inputs), TestProxy (WS-005 double presence, four segments, leading colon, case), fonoteka TestWsAuthorizer (20 cases plus editor removal); stage --named pass; removal checks RC-02 and RC-03 fail TestParseChannel, RC-04 fails TestWsAuthorizer Channel ids are the PHP ones (collection:<id>); authorization, not obscurity, protects them
T-11-03 Information Disclosure deny responses medium mitigate Every deny answers the same HTTP 200 {"error":{"code":403,"message":"Access denied"}}; the reason goes only to the Warn log TestProxy (every deny case compares the exact body and status), fonoteka TestRealtimeSubscribeProxy (deny logs carry the internal reason) pass None known
T-11-04 Information Disclosure connection token info claim medium mitigate TokenIssuer.ForUser marshals exactly sub, exp, info{name} TestTokenClaims (byte-exact claim segments for all five generators), fonoteka TestRealtimeTokenRoute pass The display name is visible to anyone holding the token, as in PHP
T-11-05 Information Disclosure broadcast audience and rolled-back writes high mitigate lighthouse/broadcast.go installCallbacks registers the after-write callbacks After(gorm:after_*) and Before(gorm:commit_or_rollback_transaction) (fixed in 11-07: an After-only anchor ran past GORM's commit) and enqueues on the write's *sql.Tx inside a savepoint; fonoteka realtime.go albumChannels publishes only for a kind = collection collection; the payload is SerializeAlbum TestBroadcastTx (commit publishes once, rollback nothing, single-statement write enqueues on its own transaction), TestSuppression, TestBulkEmitsOnce, fonoteka TestAlbumBroadcastBinding, TestAlbumBroadcastSmoke, parity TestBroadcastGoldens pass; removal checks RC-05 fails TestBroadcastTx and RC-06 fails TestAlbumBroadcastBinding Delivery order across jobs is not guaranteed, as with PHP's queued broadcast job
T-11-06 Information Disclosure logs (api key, tokens, secrets, payloads) medium mitigate The Centrifugo client sets the key only in Authorization; errors carry the method and status; the proxy never logs secrets; broadcast failures log channels and event only; the log driver omits payloads TestClientRequests (errors without the key, DebugInfo without it), TestProxy (no secret in logs), TestBroadcastPublishFailure (no payload in the failure log), TestDrivers (log driver), TestHealthCommand pass None known
T-11-07 Information Disclosure cross-collection search leak through a stale or mis-scoped index high mitigate fonoteka models/album_search.go ToSearchableArray refuses an album without a positive collection_id; typesense.Engine.SearchIDs returns candidates only and the beachcomber README requires an SQL re-gate fonoteka TestAlbumSearchable (collection_id_zero_is_refused, document keys and types), TestAlbumSearchDeleteAndFailures pass; removal check RC-07 fails TestAlbumSearchable Phase 12 owns the endpoint re-gate of SearchIDs results; until then no endpoint exposes them
T-11-08 Elevation of Privilege summer_jobs ids (cancel/progress IDOR) medium accept Phase 11 exposes no HTTP route over summer_jobs; the Phase 13 CSV endpoints must scope ids through the owning import (findVisible). Recorded for Phase 13. none (accepted) accepted Phase 13 must scope job ids through the owning import
T-11-09 Tampering scheduled-command worker high mitigate conga/scheduler.go runScheduled runs a job only when its entry id is in the compiled table and its command and args match exactly TestScheduledEntryMismatchSkipped (mismatched command, args, unknown entry, forged river_job row), fonoteka TestFonotekaScheduleSkipsUnregisteredPrune pass; removal check RC-08 fails TestScheduledEntryMismatchSkipped Whoever can write the compiled binary controls the schedule, as intended
T-11-10 Denial of Service token and subscribe flooding medium mitigate fonoteka ws-api bucket (120/min per user or IP) on both routes, jwt.auth before the throttle on the token route; ProxyHandler caps the body at 64 KiB TestProxy/oversized_body, fonoteka TestRealtimeTokenRoute (route order), TestRealtimeSubscribeProxy (raw route with throttle:ws-api), TestAllRouteGroupsBoot pass A distributed flood stays within per-IP limits
T-11-11 Information Disclosure VAPID private key medium mitigate flare prints a private key only when newly generated; --show-current truncates; Config and VAPIDKeys redact it in String, GoString and LogValue TestGenerateVAPIDKeysCommand, TestTestPushCommand, TestVAPIDKeys (formatting), TestVAPIDSendRoundTrip pass The generated key is shown once on the operator's terminal
T-11-12 Tampering Dispatch/Enqueue (orphan jobs for rolled-back writes) high mitigate conga/conga.go Dispatch writes the row and runs InsertTx on the caller's *sql.Tx (its own transaction when there is none); Enqueue joins a caller's transaction TestDispatchTransactional (commit, own transaction, rollback leaves neither row nor job) pass; removal check RC-09 fails TestDispatchTransactional None known
T-11-13 Denial of Service River listener pool medium mitigate conga/worker.go listenerPool: a dedicated pgx pool with MaxConns 1, MinConns 0 per worker, closed by Worker.Stop; README documents session pooling for PgBouncer TestListenPickupLatency (LISTEN pickup under 1 s, poll-only control), TestWorkerStopFallsBackToHardStop; stage --go runs the LISTEN test three times pass (pool size checked by code review) A PgBouncer in transaction mode breaks LISTEN; documented
T-11-14 Tampering queue:clear medium mitigate conga/commands.go clearQueue deletes only available, scheduled and retryable jobs of one queue, in 10000-job batches TestQueueClear (running job untouched), TestClearQueueStatesAndBatches (10006 jobs over two batches, finished and other-queue jobs kept) pass None known
T-11-15 Information Disclosure job failure metadata and logs medium mitigate runAttempt writes only the error text under metadata error; job args are never logged; River logs through the app logger TestOutcomeFailFinalAttemptOnly (metadata is the dispatch metadata plus the error text), TestOutcomePanicBecomesError pass A job that puts secrets in its own error text would store them
T-11-16 Denial of Service panicking plugin jobs medium mitigate Manager.call recovers panics into errors; the final-attempt ERROR rule applies TestOutcomePanicBecomesError pass None known
T-11-17 Denial of Service periodic enqueue on leader failover low mitigate Scheduled runs are unique by args within the cadence period; Daily/Every are wall-clock schedules TestScheduleUniqueByPeriod, TestScheduleNext (DST in Europe/Warsaw), TestScheduleOrdering (insert options) pass Multi-process leader election is River's guarantee (backstop, not tested)
T-11-18 Repudiation scheduled runs low mitigate Each run, skip and failure is logged with the command name (and duration); unregistered commands are Warn TestScheduleRunsCommand, TestScheduleMissingCatalog, TestScheduleLogWriter, fonoteka TestFonotekaScheduleSkipsUnregisteredPrune pass None known
T-11-19 Elevation of Privilege Mount of a user route without a guard high mitigate lighthouse/route.go validateRoute refuses a UserAuth route when Surfaces.UserAuth is empty, before any route is mounted TestMountSurfaces (user_route_without_guard, nothing mounted on refusal) pass; removal check RC-10 fails TestMountSurfaces None known
T-11-20 Denial of Service broadcast failure aborting the write transaction medium mitigate lighthouse/broadcast.go inSavepoint rolls back to its savepoint on an error and, since 11-07, also when the release fails because a swallowed read aborted the transaction TestBroadcastEdges (channel, payload and query failures keep the write), TestBroadcastSwallowedReadFailure (failed RED before the 11-07 fix) pass None known
T-11-21 Repudiation phase gate fail-open medium mitigate scripts/check-phase11.sh: each detector returns on its first violation; skipped, missing or zero tests and "no tests to run" fail the gate; only the two Phase 12 goldens may skip, with their pending text check-phase11.sh --self-test (18 detector cases, 10 hygiene plants each refused for its own rule, a clean look-alike, the removal harness on a scratch module) pass None known
T-11-22 Spoofing / SSRF push endpoint requests high mitigate flare/flare.go checkEndpoint allows only https URLs without user info whose host passes HostAllowed(push.allowed_hosts), before dialing; redirects are never followed TestSendAllowlist (host table and refused endpoints), TestSendRefusesDisallowedEndpoint (redirects, tricks, no request made) pass; removal check RC-11 fails TestSendAllowlist and TestSendRefusesDisallowedEndpoint A compromised allowed push service is out of scope
T-11-23 Information Disclosure websockets:health output low mitigate websockets:health prints only "API Key Set: Yes/No" TestHealthCommand pass None known
T-11-24 Information Disclosure persisted overrides file medium mitigate --update goes through compass Persist (atomic write, mode 0600, merge over earlier overrides) TestGenerateVAPIDKeysCommand (mode 0600, earlier override kept), compass TestPersistKeepsEarlierOverrides pass None known
T-11-25 Information Disclosure Typesense API key in logs or errors medium mitigate typesense.Engine sets the key only in X-TYPESENSE-API-KEY; StatusError carries method, path and status, never the body; transport errors drop the URL; sync warnings name index, key and operation TestEngineWire (errors without body, key or URL), TestSyncFailuresNonFatal (no key in the warning), TestSyncEngineRegistration pass None known
T-11-26 Denial of Service search failure blocking or failing writes medium mitigate beachcomber/sync.go: sync runs after commit, bounded by the engine timeout; every error and panic is one Warn; reads run in a savepoint that, since 11-07, is also rolled back when a swallowed read failed TestSyncFailuresNonFatal (engine error and panic, document error and panic, failed gate read in a caller's transaction: failed RED before the 11-07 fix), fonoteka TestAlbumSearchDeleteAndFailures pass A slow Typesense adds up to the connection timeout to a write's latency
T-11-27 Information Disclosure data sent while the kill-switch is off medium mitigate Gates before any request: engine configured (API key), database published, application Gate (fonoteka settingsGate, errors mean off) TestSyncGates (null engine, empty key, no database, gate off), fonoteka TestAlbumSearchable/settings_gate, TestAlbumSearchSmoke (zero requests) pass None known
T-11-28 Information Disclosure goldens and route fixtures high mitigate tide/centrifugo.go records only whether Authorization matched; fonoteka parity/check_corpus.go fails on the test-only Centrifugo values and on an X-Centrifugo-Secret that is not a {{var}} or the documented deny literal TestCentrifugoRecorder (value never stored), TestCentrifugoRecorderRecordsPublishAndBroadcast, parity TestUniqueAndSecretScan pass; removal check RC-12 fails TestUniqueAndSecretScan The corpus scan knows only the test-only values; live secrets never reach the isolated PHP
T-11-29 Spoofing recorder listener and parity:broadcasts target medium mitigate CentrifugoRecorder.ListenAndServe and RecordBroadcasts require loopback addresses (tide's T-02-01 rule) TestCentrifugoRecorderRefusesNonLoopback, TestCentrifugoRecorder (loopback serve and shutdown), TestRecordBroadcastsStep pass None known
T-11-30 Repudiation golden normalisation hiding regressions medium mitigate NormalizePublications masks only +00:00 timestamps, an exact {user_id, name} actor and captured ids under id keys; DiffPublications compares count, path, authorization and body; pending goldens skip, never pass TestNormalizePublications, TestDiffPublications, parity TestBroadcastGoldens (created/updated must skip with "pending: Phase 12", enforced by --named and --postgres) pass Phase 12 must turn created/updated into assertions
T-11-31 Information Disclosure lagoon.AfterCommit / beachcomber sync high mitigate modules/lagoon/transaction.go AfterCommit buffers inside lagoon.Transaction and GORM's implicit single-statement transaction, and inside a foreign plain GORM *sql.Tx logs a warning and skips the callback; nested calls must use the exact parent transaction connection, so root and unrelated transaction handles are rejected; Cabana create/update/delete/bulk-delete and relation Link/Unlink and fonoteka SaveAlbum run in lagoon.Transaction TestTransactionAfterCommit (plain_gorm_transaction_is_refused, nested_rejects_unrelated_transaction_handle), TestTransactionEdges (nested root handle), beachcomber TestSyncAfterCommit (plain_gorm_transaction_rollback_sends_nothing), fonoteka TestAlbumSearchDeleteAndFailures (foreign transaction rollback never reaches Typesense); stages --go, --postgres, --named pass; removal checks RC-14 and RC-15 fail TestTransactionAfterCommit Code that writes inside its own plain GORM transaction gets no search sync (warned, not silent) until it adopts lagoon.Transaction or calls Sync after commit
T-11-32 Tampering Cabana and SaveAlbum ordered artist pivots medium mitigate The Album row and its ordered artist pivots commit in one lagoon.Transaction (Cabana CRUDService.save, RelationService.Link/Unlink, fonoteka SaveAlbum), and the buffered sync reloads the committed row and pivots fonoteka TestAlbumsAdminSearchUsesCommittedArtists (assembled admin router; indexed artist_ids equal the committed pivot order), TestSaveAlbumDefersAfterCommitUntilArtistsSync (callback sees the committed order; none on rollback) pass; with Cabana save reverted to a plain transaction the admin test fails (no import), and with SaveAlbum reverted the SaveAlbum test fails (no callback), both checked by hand in the 11-08 close-out Concurrent commits for the same Album are not serialized; the Phase 12 SQL re-gate of SearchIDs results remains the boundary
T-11-SC Tampering Go module installs (River v0.47.0 and sub-modules) high mitigate River is pinned at v0.47.0 with go.sum checksums; no Centrifugo, Typesense or Web Push client and no cron library was added in any plan check-phase11.sh --hygiene (client libraries in go list -m all of both repositories, direct cron requirements, River version), --self-test (plants) pass; removal check RC-13 (client rule disabled) fails --self-test robfig/cron/v3 is in the module graph only as River's test dependency, not a requirement

Removal checks

Each row is one anchor-exact mutation from scripts/check-phase11.sh --removal. The anchor occurs exactly once in the file; the test must fail on an assertion (not a build failure); the file is restored and compared with cmp against the copy saved before the mutation. All fifteen were run on 2026-09-30 and all failed as required; git status was clean in both repositories afterwards. RC-14 and RC-15 were added and run in the 11-08 close-out/review and failed as required.

Check Threat File Anchor removed or changed Replacement Test run Observed
RC-01 T-11-01 modules/lighthouse/centrifugo/handlers.go if cfg.ProxySecret == "" || subtle.ConstantTimeCompare(...) != 1 { if subtle.ConstantTimeCompare(...) == 2 { go test ./modules/lighthouse/centrifugo -run '^TestProxy$' fails: TestProxy/missing_secret, wrong_secret, secret_prefix, empty_configured_secret_denies_everything; restored, cmp ok
RC-02 T-11-02 modules/lighthouse/channel.go if strings.HasPrefix(channel, presencePrefix+presencePrefix) { if false { go test ./modules/lighthouse -run '^TestParseChannel$' fails: TestParseChannel (presence:presence: cases); restored, cmp ok
RC-03 T-11-02 modules/lighthouse/channel.go if len(parts) > 3 { if false { go test ./modules/lighthouse -run '^TestParseChannel$' fails: TestParseChannel (four-segment cases); restored, cmp ok
RC-04 T-11-02 ../fonoteka.go/plugins/golem15/fonoteka/classes/ws/collection_authorizer.go Where("golem15_fonoteka_collections.kind = ?", "collection"). removed go test ./plugins/golem15/fonoteka -run '^TestWsAuthorizer$' fails: TestWsAuthorizer/wishlist_id_under_collection_namespace; restored, cmp ok
RC-05 T-11-05 modules/lighthouse/broadcast.go cb.Create().After("gorm:after_create").Before(commitCallback).Register( cb.Create().After("gorm:after_create").Register( go test ./modules/lighthouse -run '^TestBroadcastTx$' fails: TestBroadcastTx/single_statement_write_enqueues_in_its_own_transaction; restored, cmp ok
RC-06 T-11-05 ../fonoteka.go/plugins/golem15/fonoteka/realtime.go if c.Kind != "collection" { if false { go test ./plugins/golem15/fonoteka -run '^TestAlbumBroadcastBinding$' fails: TestAlbumBroadcastBinding/channels/wishlist_album; restored, cmp ok
RC-07 T-11-07 ../fonoteka.go/plugins/golem15/fonoteka/models/album_search.go if a == nil || a.CollectionID == 0 { if a == nil { go test ./plugins/golem15/fonoteka -run '^TestAlbumSearchable$' fails: TestAlbumSearchable/collection_id_zero_is_refused; restored, cmp ok
RC-08 T-11-09 modules/conga/scheduler.go if !ok || entry.Command != a.Command || !slices.Equal(entry.Args, a.Args) { if !ok { go test ./modules/conga -run '^TestScheduledEntryMismatchSkipped$' fails: TestScheduledEntryMismatchSkipped; restored, cmp ok
RC-09 T-11-12 modules/conga/conga.go res, err := client.InsertTx(ctx, sqlTx, args, opts) _ = sqlTx then res, err := client.Insert(ctx, args, opts) go test ./modules/conga -run '^TestDispatchTransactional$' fails: TestDispatchTransactional/rollback (River job survives the rollback); restored, cmp ok
RC-10 T-11-19 modules/lighthouse/route.go if len(s.UserAuth) == 0 { if false { go test ./modules/lighthouse -run '^TestMountSurfaces$' fails: TestMountSurfaces/user_route_without_guard; restored, cmp ok
RC-11 T-11-22 modules/flare/flare.go if !HostAllowed(host, p.cfg.AllowedHosts) { if false { go test ./modules/flare -run '^(TestSendAllowlist|TestSendRefusesDisallowedEndpoint)$' fails: TestSendAllowlist, TestSendRefusesDisallowedEndpoint; restored, cmp ok
RC-12 T-11-28 ../fonoteka.go/parity/check_corpus.go if strings.Contains(text, v) { (centrifugo test value scan) if false && strings.Contains(text, v) { go test ./parity -run '^TestUniqueAndSecretScan$' fails: TestUniqueAndSecretScan; restored, cmp ok
RC-13 T-11-SC scripts/check-phase11.sh (mutated copy) hits="$(grep -iE "$CLIENT_RE" "$modlist" | head -n1 || true)" hits="" bash <copy> --self-test fails: "refuse: self-test hygiene_11 accepted a planted client-gocent"; original untouched, cmp ok
RC-14 T-11-31 modules/lagoon/transaction.go if transactionalHandle(db) { (foreign-transaction refusal in AfterCommit) if false { go test ./modules/lagoon -run '^TestTransactionAfterCommit$' fails: TestTransactionAfterCommit/plain_gorm_transaction_is_refused, callback_handle_has_a_clean_statement; restored, cmp ok
RC-15 T-11-31 modules/lagoon/transaction.go if !parent.owns(gdb) { (exact parent transaction identity) if false { go test ./modules/lagoon -run '^TestTransactionAfterCommit$' fails: TestTransactionAfterCommit/nested_rejects_unrelated_transaction_handle; restored, cmp ok

Fixes made during the review

Defect Threat Fix Failing-when-broken test Commit
A single-statement write enqueued its broadcast job after GORM's own commit, outside the write transaction T-11-05 lighthouse after-write callbacks also declare Before("gorm:commit_or_rollback_transaction") TestBroadcastTx/single_statement_write_enqueues_in_its_own_transaction (RED: channels ran on the pool, not the write's *sql.Tx) summercms.go 6f50b6c
After-commit callbacks received a handle carrying the written model's statement; a WithContext query read the written model's table T-11-26 lagoon passes a clean handle (Session{NewDB, Context}, Clauses(), Session{NewDB}) on all three after-commit paths TestTransactionAfterCommit/callback_handle_has_a_clean_statement (RED: SELECT ... "lagoon_ac_items"."label" and an aborted plain transaction) summercms.go c544319
A read failure swallowed inside the sync or broadcast savepoint left the caller's transaction aborted (25P02) T-11-20, T-11-26 beachcomber and lighthouse roll back to the savepoint when its release fails TestSyncFailuresNonFatal/failed_gate_read_keeps_the_callers_transaction, TestBroadcastSwallowedReadFailure (both RED with 25P02) summercms.go 6df43d4
Search sync ran inside plain GORM transactions, before the Album's artist pivots were written, and survived a rollback (verifier gap CR-01) T-11-31, T-11-32 Cabana writes and SaveAlbum use lagoon.Transaction; lagoon.AfterCommit refuses a foreign *sql.Tx; a nested lagoon.Transaction over a root handle errors TestAlbumsAdminSearchUsesCommittedArtists, TestSaveAlbumDefersAfterCommitUntilArtistsSync, TestTransactionAfterCommit/plain_gorm_transaction_is_refused, TestSyncAfterCommit/plain_gorm_transaction_rollback_sends_nothing summercms.go f7b6b0c, a33b1ad, 2766f34; fonoteka.go 1c88199