Files
summercms/.planning/phases/12-p-ytarium-api-collections-and-albums/12-SECURITY-REVIEW.md
Jakub Zych 1f4e1e01c4 docs(12-05): sign off the Phase 12 security review, validation and API-01/API-02
- 12-SECURITY-REVIEW.md maps T-12-01..T-12-34 and T-12-SC to a named test
  and 25 removal checks, all seen failing with the protection removed
- 12-VALIDATION.md validated with the final per-task map, nyquist_compliant
- REQUIREMENTS.md: API-01 and API-02 complete
2026-10-02 16:55:29 +02:00

29 KiB

phase, reviewed, reviewer, threats_open, gate, removal_harness
phase reviewed reviewer threats_open gate removal_harness
12 2026-10-02 gsd-executor, plan 12-05 (self-performed code-and-test review of plans 12-01 to 12-05; no reviewer agent was spawned, per the 08-10 precedent) 0 scripts/check-phase12.sh --all scripts/check-phase12.sh --removal

Phase 12 Security Review

This is a code-and-test review of every threat in the registers of Plans 12-01 to 12-05 (T-12-01 to T-12-34 and T-12-SC). Severity and disposition are copied from the originating plan; T-12-SC is declared by every plan and is listed once with the strictest entry (12-01: high, mitigate, the golang.org/x/image bump), the later plans adding no dependency. The executor performed the review itself, as plan 08-10 did, because no separate reviewer agent was spawned.

A high threat counts as mitigated only when its named test or gate stage fails with the protection removed. scripts/check-phase12.sh --removal does this for every high mitigated threat: it refuses a file with uncommitted changes, 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 under "Removal checks". The one accepted threat keeps its rationale from its originating plan.

The review found four defects in Phase 12 code and two in the 12-01 framework port, all fixed in plan 12-05 with a failing-when-broken test (see "Fixes made during the review"). The most serious: a 100-byte PNG declaring a 30000x30000 canvas, uploaded by any household member, made every later listing of the collection and the album answer 500 (T-12-16).

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

Threat Category Component Severity Disposition Production mitigation Test or gate stage Observed result Residual risk
T-12-01 Information Disclosure collections/{id}, photos/{fileId}, image high mitigate controllers/api/collections_controller.go findAccessibleCollection scopes every lookup with classes.AccessibleBy(user, token); photo and image lookups are scoped to the collection's own system_files rows; foreign and missing ids share one 404 body TestPhase12Threats/T-12-01 (GET/PUT/DELETE, photo, image and a foreign photo id: identical 404s, the foreign photo stays attached), TestCollectionsIndexBothGroups, parity 404 fixtures; stage --named pass; removal check RC-01 fails TestPhase12Threats/T-12-01 None known
T-12-02 Information Disclosure albums/search items and meta.total high mitigate classes/album_search.go re-gates every engine id with ScopedAlbums (AlbumsAccessibleBy plus the active collection) for the page and for the total; engine errors fall back to SQL TestSearchLeak (stale-moved, mis-scoped, soft-deleted, removed-editor and its resolve race, token-pin, on both groups, page ids and meta.total compared exactly) pass; removal checks RC-02 and RC-04 fail TestSearchLeak A stale index document shortens a page, as Scout does
T-12-03 Elevation of Privilege personal token pin high mitigate classes/access.go AccessibleBy narrows to the pin (plus the owner's wishlist) as a separate grouped AND; Resolve needs exactly one accessible pinned id; SwitchTo refuses tokens TestPhase12Threats/T-12-03 (token collections list holds only the pin; the owner's other collection is a 404 like a missing one; SwitchTo refuses), TestResolvePinnedToken, TestCollectionsIndexBothGroups pass; removal check RC-05 fails TestPhase12Threats/T-12-03 None known
T-12-04 Elevation of Privilege owner-only interactive routes on the token group high mitigate routes.go mounts switch, collection/share, me/context, realtime/channels, sync and missing on the JWT group only; the share handler and SwitchTo also refuse a token TestRouteTablePhase12 (no missing or unexpected route, every JWT-only path absent from the token group), TestPhase12Threats/T-12-04 (each path unmounted for a token; the share token unchanged) pass; removal check RC-07 fails TestRouteTablePhase12 None known
T-12-05 Elevation of Privilege editor acting as owner high mitigate Explicit owner_id == user checks in collection destroy, image upload and delete, and the share surface; editors get the 404 TestPhase12Threats/T-12-05 (editor delete, image upload/delete and share show/update/regenerate refused; the row, its image and share token unchanged), TestShareOwnerOnly pass; removal check RC-10 fails TestPhase12Threats/T-12-05 None known
T-12-06 Spoofing invitation accept high mitigate classes/invitation_service.go AcceptInvitation looks the invitation up by sha256 FOR UPDATE, requires it pending, unexpired, unrevoked and addressed to the caller's normalized e-mail, else 410; resend rotates the hash TestPhase12Threats/T-12-06 (another user's, unknown, reused, expired, revoked and rotated tokens all 410), TestInvitationAcceptAddsEditor, TestConcurrentAcceptSingleEditor pass; removal checks RC-12 (e-mail check) and RC-13 (sha256 lookup) fail TestPhase12Threats/T-12-06 A leaked invitation link is usable by its addressee until accepted or expired, as in PHP
T-12-07 Information Disclosure raw token in river_job.args, summer_jobs and logs high mitigate enqueueInvitationMail stores the token in the job args only as lagoon.Encrypted ciphertext under the app key, through Enqueue (never summer_jobs); nothing logs args, token or link TestPhase12Threats/T-12-07 (no 64-hex value in river_job.args, the decrypted token matches the stored hash and appears neither in args nor in the captured logs nor in summer_jobs), TestInvitationMailEnqueuedInTx pass; removal check RC-14 fails TestPhase12Threats/T-12-07 Whoever holds the app key can decrypt queued tokens
T-12-08 Spoofing share token high mitigate classes/share_service.go draws 16 symbols of a 62-symbol alphabet from crypto/rand with rejection at 248, retries on a unique clash, mutates under FOR UPDATE, never logs TestPhase12Threats/T-12-08 (300 unique tokens of the alphabet, rejection sampling over scripted bytes, an exhausted source fails), TestShareTokenAlphabet pass; removal check RC-15 fails TestPhase12Threats/T-12-08 About 95 bits per token
T-12-09 Tampering / Information Disclosure cover_urls and cover_url fetches (SSRF) high mitigate Manual cover URLs go through fetchguard PublicOnly (https only, dial-time private-address refusal, no redirects, byte cap, timeout); cover imports through fetchguard AllowHosts (.discogs.com), after the write commits TestPhase12Threats/T-12-09 (http, ftp, loopback, RFC 1918, link-local metadata, IPv6 loopback and ULA refused with PHP's reasons; importer refuses non-Discogs, non-https and private hosts and attaches nothing), TestManualCoverReasons, TestCoverImportAfterCommit, fetchguard's own tests pass; removal check RC-16 (fetch without fetchguard) fails TestPhase12Threats/T-12-09 DNS rebinding is refused at dial time by fetchguard
T-12-10 Tampering / Denial of Service photo upload high mitigate Router body cap, the max:10240 file rule, classes.IsAllowedImage (sniff plus DecodeConfig), throttle:20,1 on the JWT photo route TestPhase12Threats/T-12-10 (a PNG-signature polyglot and an HTML file refused before any row is written, an over-cap body 413, the 21st upload in a minute 429), TestAlbumPhotoUpload pass; removal check RC-17 fails TestPhase12Threats/T-12-10 The token group's photo route carries only the token bucket, as in PHP
T-12-11 Tampering mass assignment on albums and bulk high mitigate AlbumFillFields plus a per-field setter (setAlbumField has no server-owned column) and a server-set collection_id; market_price_source is only ever cleared FuzzWriteEndpoints (all 41 Phase 12 write routes from the route table; every server-owned key with a hostile value plus fuzzed keys; Postgres snapshots allow only each route's own columns; 82 seeds in plain go test, 60 s fuzzing clean) pass; removal check RC-18 (collection_id taken from the input) fails FuzzWriteEndpoints None known
T-12-12 Tampering concurrent resolve provisioning medium mitigate users row FOR UPDATE before the context row, the unique user_id context key TestPhase12Threats/T-12-12 (six concurrent first resolves provision one collection), TestResolveProvisionsOnce pass None known
T-12-13 Elevation of Privilege realtime/channels raw id low accept Matches PHP; the id is the caller's own resolved collection, never accepted as a tenant selector on any write, and the collection authorizer re-validates membership on every subscribe. none (accepted) accepted The channel name reveals the caller's own collection id
T-12-14 Tampering lagoon exists: and regex rules high mitigate modules/lagoon/validate_rules.go ParseRules checks table and column names against identName at registration (panics on a bad one); the value is bound, compared as text; regexes come only from code and must compile in RE2 TestValidateRequestParseRules, TestValidateRequestExistsRule (injection-shaped values are plain misses, an unsafe inferred column and a missing table are errors), TestPhase12Threats/T-12-14 pass; removal check RC-20 fails TestValidateRequestParseRules None known
T-12-15 Denial of Service wildcard expansion and size rules medium mitigate Expansion is bounded by the decoded body, which surf caps; size counts are linear; RE2 has no backtracking TestPhase12Threats/T-12-15 (20000 wildcard rows validate in about a second; an over-cap JSON body is 413), TestValidateRulesMatchLaravel pass None known
T-12-16 Denial of Service webp/png/jpeg decode in Thumb and DecodeConfig medium mitigate attach.File.Thumb reads the size from the header and never decodes more than 4096x4096 pixels; since 12-05 an unusable original gets WinterCMS's broken-image picture instead of an error (fixed: the error made every later listing 500) TestThumbBrokenSourceServesPlaceholder, TestPhase12Threats/T-12-16 (a 30000x30000 header-only PNG uploads at once and the listings still answer 200), TestThumbModesAndFormats pass None known
T-12-17 Information Disclosure tide multipart part files medium mitigate Part bytes are committed test images pinned by sha256; substitution never touches file bytes; check_corpus --check-secrets scans every YAML TestPhase12Threats/T-12-17 (each part file is an image or the one short text fixture, no 64-hex value), TestMultipartTamperedPartFileFailsLoad, TestMultipartPartEdges (symlink out of the fixture directory refused) pass None known
T-12-18 Elevation of Privilege user groups table medium mitigate No route writes users_groups; User.Groups is json:"-"; the site-admin predicate reads group codes server-side TestPhase12Threats/T-12-18 (no write route mentions groups; a user with groups marshals and answers me/context without them), TestUserGroupCodesAndHasGroupCode, TestMeContextFlags pass None known
T-12-19 Information Disclosure Winter error pages low mitigate The embedded pages are PHP's APP_DEBUG=false bytes with only the stylesheet origin templated from app.url TestPhase12Threats/T-12-19 (no stack trace, path, line number or exception class in any page or a live 404), TestWinterErrorWriters pass None known
T-12-20 Information Disclosure committed flow fixtures high mitigate Raw invitation tokens appear only as {{secret:invite}}; parity/check_corpus.go --check-secrets fails on any 64-hex value except a multipart sha256 pin TestCheckCorpusInvitationToken, TestPhase12Threats/T-12-20 (every fixture scanned), check-phase12.sh --parity (runs check_corpus.go --require-recorded --check-secrets) pass; removal check RC-21 fails TestCheckCorpusInvitationToken None known
T-12-21 Denial of Service invite and resend mail flooding medium mitigate throttle:10,1 on store and resend; one pending row per (collection, e-mail) TestPhase12Threats/T-12-21 (a repeated e-mail reuses its row; the store is throttled within ten requests), TestRouteTablePhase12 (inline throttles on exactly their routes) pass None known
T-12-22 Tampering accept joining the inviter's organisation medium mitigate Only an org-less invitee joins, as member, inside the accept transaction TestPhase12Threats/T-12-22 (an invitee's own organisation stays; an org-less invitee joins as member) pass None known
T-12-23 Information Disclosure album_added notifications and broadcasts medium mitigate Recipients are the collection's owner and editors minus the actor; broadcasts only on the kind=collection channel, after commit TestPhase12Threats/T-12-23 (an editor's album notifies only the owner), TestAlbumAddedNotifiesHousehold, TestBroadcastGoldens pass None known
T-12-24 Denial of Service search recount and bulk size medium mitigate Recount capped at 1000 ids in pages of 250; bulk bounded by the body cap and validation; cover imports capped at max_covers TestSearchLeak/cap, TestPhase12Threats/T-12-24 (six cover_urls 422, an over-cap bulk 413, the importer stops at max_covers) pass None known
T-12-25 Tampering gate --removal leaving mutated source medium mitigate check-phase12.sh --removal refuses a file with uncommitted changes, mutates by exact anchor, restores in a finally and compares with cmp check-phase12.sh --self-test (removal harness on a scratch module: a guarded mutation fails, a surviving one is reported, a non-unique anchor and a dirty file are refused, the file is restored) pass; both repositories clean after the 25 removal runs An interrupted run (SIGKILL) could leave a mutation; git status shows it
T-12-26 Repudiation security review claims without evidence medium mitigate check-phase12.sh --evidence refuses a threat without one review row, a high mitigated threat without a removal row, a mitigated threat naming no test, and a validation row that is not green or names a test the gate does not run check-phase12.sh --evidence, check-phase12.sh --self-test (five plants refused) pass The review text itself is written by the executor
T-12-27 Information Disclosure fuzz seed corpus low mitigate Seeds hold synthetic values only; the gate scans testdata/fuzz for 64-hex values, inv_ tokens, JWTs and bearer headers check-phase12.sh --parity (corpus scan), check-phase12.sh --self-test (four planted secrets and an empty corpus refused) pass; removal check RC-25 fails --self-test None known
T-12-28 Information Disclosure beachcomber SearchPage found count high mitigate found is used only as the size of the SQL recount (D-19); the total exposed is the re-gated count of at most 1000 engine ids TestSearchLeak (short-page, recount, cap: no poisoned id counted, the recount pages of 250, never beyond 1000), TestSearchPageUsesPageSearcher pass; removal check RC-03 fails TestSearchLeak None known
T-12-29 Denial of Service collection photo/image upload medium mitigate Body cap, max:10240, image and mimes sniff before the blob write TestPhase12Threats/T-12-29 (a non-image refused on photos and image with no row written), TestCollectionPhotoUpload pass None known
T-12-30 Tampering mass assignment on collections high mitigate CollectionFillFields holds only name and description; owner_id, kind and the share columns are server-set FuzzWriteEndpoints (collection create, update, share and delete routes with every server-owned key hostile) pass; removal check RC-19 (owner_id fillable) fails FuzzWriteEndpoints None known
T-12-31 Elevation of Privilege household routes under a personal token high mitigate Household and invitation routes are on the JWT group only; assertInvitationOwner and the handlers' ownerCollection also refuse a token TestRouteTablePhase12, TestPhase12Threats/T-12-31 (every household path unmounted for a token; SendInvitation and RemoveEditor refuse a token) pass; removal checks RC-08 (route) fails TestRouteTablePhase12 and RC-09 (service lock) fails TestPhase12Threats/T-12-31 None known
T-12-32 Elevation of Privilege editor managing members or invitations high mitigate ownerCollection checks the resolved collection's owner before every household action; editors get the 404 page TestPhase12Threats/T-12-32 (every household action of an editor is the 404 page and changes nothing), TestHouseholdInvitationsIndexRoute pass; removal check RC-11 fails TestPhase12Threats/T-12-32 None known
T-12-33 Information Disclosure albums/{id}, photos/{fileId}, rating high mitigate FindAccessibleAlbum is ScopedAlbums plus the id; photo lookups match the album's own attachments; one 404 body for foreign, out-of-context and missing ids TestPhase12Threats/T-12-33 (every id route on both groups, a foreign and an out-of-context album, another album's photo id) pass; removal checks RC-22 (photo scoping) and RC-23 (album scoping) fail TestPhase12Threats/T-12-33 None known
T-12-34 Elevation of Privilege token pin on albums, stats, value, search, lookups high mitigate Resolve answers a token with its pin, never the owner's stored context; AlbumsAccessibleBy narrows; sync and missing are JWT only TestPhase12Threats/T-12-34 (a pinned token sees only its collection on albums, stats, value, search and artists while the owner's context is elsewhere), TestSearchLeak/token-pin, TestRouteTablePhase12 pass; removal check RC-06 fails TestPhase12Threats/T-12-34 None known
T-12-SC Tampering Go module installs (golang.org/x/image v0.46.0) high mitigate golang.org/x/image pinned at v0.46.0 with go.sum checksums (D-24); no other dependency was added in Phase 12 check-phase12.sh --go (module pin over go list -m all of both repositories), --self-test (plants) pass; removal check RC-24 (pin check disabled) fails --self-test None known

Removal checks

Each row is one anchor-exact mutation from scripts/check-phase12.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 twenty-five were run on 2026-10-02 and all failed as required; git status was clean in both repositories afterwards. RC-12 and RC-22 first failed to build (an unused variable and an unused import) and were rewritten to compile before being run again.

Check Threat File Anchor removed or changed Replacement Test run Observed
RC-01 T-12-01 ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go Scopes(classes.AccessibleBy(userID, token)). in findAccessibleCollection removed go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-01$' fails: TestPhase12Threats/T-12-01; restored, cmp ok
RC-02 T-12-02 ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go ScopedAlbums(...) on the page re-gate an unscoped album query go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' fails: stale-moved, mis-scoped, token-pin, short-page, recount, removed-editor (both groups); restored, cmp ok
RC-03 T-12-28 ../fonoteka.go/plugins/golem15/fonoteka/classes/album_search.go ScopedAlbums(...) on the total recount an unscoped album query go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' fails: every total assertion, including cap; restored, cmp ok
RC-04 T-12-02 ../fonoteka.go/plugins/golem15/fonoteka/classes/album_queries.go Scopes(AlbumsAccessibleBy(userID, token)). in ScopedAlbums removed go test ./plugins/golem15/fonoteka -run '^TestSearchLeak$' fails: token-pin, removed-editor/race; restored, cmp ok
RC-05 T-12-03 ../fonoteka.go/plugins/golem15/fonoteka/classes/access.go if pin := tokenCollectionPin(token); len(pin) > 0 { ... false && len(pin) > 0 { go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-03$' fails: TestPhase12Threats/T-12-03; restored, cmp ok
RC-06 T-12-34 ../fonoteka.go/plugins/golem15/fonoteka/classes/active_collection.go if token != nil { before resolvePinnedTokenCollection if false { go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-34$' fails: TestPhase12Threats/T-12-34; restored, cmp ok
RC-07 T-12-04 ../fonoteka.go/plugins/golem15/fonoteka/routes.go the token group's g.Get("/me", ...) plus a token GET /collection/share go test ./plugins/golem15/fonoteka -run '^TestRouteTablePhase12$' fails: router-matches-table, jwt-only-routes-absent-from-token-group; restored, cmp ok
RC-08 T-12-31 ../fonoteka.go/plugins/golem15/fonoteka/routes.go the token group's g.Get("/me", ...) plus a token GET /household/members go test ./plugins/golem15/fonoteka -run '^TestRouteTablePhase12$' fails: router-matches-table, jwt-only-routes-absent-from-token-group; restored, cmp ok
RC-09 T-12-31 ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go if token != nil || actor == nil || ... in assertInvitationOwner without token != nil go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-31$' fails: TestPhase12Threats/T-12-31; restored, cmp ok
RC-10 T-12-05 ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/collections_controller.go if c == nil || c.OwnerID != user.ID { in collection destroy if c == nil { go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-05$' fails: TestPhase12Threats/T-12-05; restored, cmp ok
RC-11 T-12-32 ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/invitations_controller.go if c.OwnerID != user.ID { in ownerCollection if false { go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-32$' fails: TestPhase12Threats/T-12-32; restored, cmp ok
RC-12 T-12-06 ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go if inv.Email != email { in AcceptInvitation if false && inv.Email != email { go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-06$' fails: TestPhase12Threats/T-12-06; restored, cmp ok
RC-13 T-12-06 ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go invitationTokenHash(rawToken) in the accept lookup rawToken go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-06$' fails: TestPhase12Threats/T-12-06; restored, cmp ok
RC-14 T-12-07 ../fonoteka.go/plugins/golem15/fonoteka/classes/invitation_service.go InvitationMailArgs{InvitationID: invitationID, Token: s} (ciphertext) Token: raw go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-07$' fails: TestPhase12Threats/T-12-07; restored, cmp ok
RC-15 T-12-08 ../fonoteka.go/plugins/golem15/fonoteka/classes/share_service.go shareRejectAt = 248 = 255 go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-08$' fails: TestPhase12Threats/T-12-08; restored, cmp ok
RC-16 T-12-09 ../fonoteka.go/plugins/golem15/fonoteka/classes/manual_cover_fetcher.go return fetchguard.Fetch(ctx, rawURL, policy, nil) a fetch that skips fetchguard go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-09$' fails: TestPhase12Threats/T-12-09; restored, cmp ok
RC-17 T-12-10 ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go the body of IsAllowedImage return true first go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-10$' fails: TestPhase12Threats/T-12-10; restored, cmp ok
RC-18 T-12-11 ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go a.CollectionID = collectionID in CreateAlbum collection_id taken from the input go test ./plugins/golem15/fonoteka -run '^FuzzWriteEndpoints$' fails: FuzzWriteEndpoints (every album-create seed); restored, cmp ok
RC-19 T-12-30 ../fonoteka.go/plugins/golem15/fonoteka/classes/collection_write_service.go CollectionFillFields = []string{"name", "description"} plus "owner_id" go test ./plugins/golem15/fonoteka -run '^FuzzWriteEndpoints$' fails: FuzzWriteEndpoints (the collection update seeds on both groups); restored, cmp ok
RC-20 T-12-14 modules/lagoon/validate_rules.go if !identName.MatchString(table) { in exists parsing if false { go test ./modules/lagoon -run '^TestValidateRequestParseRules$' fails: TestValidateRequestParseRules; restored, cmp ok
RC-21 T-12-20 ../fonoteka.go/parity/check_corpus.go if hex64Re.MatchString(line) { if false && ... go test ./parity -run '^TestCheckCorpusInvitationToken$' fails: planted_path, planted_body, planted_bare; restored, cmp ok
RC-22 T-12-33 ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/album_photos_controller.go the photo lookup's attachment type, id and field conditions the file id alone go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-33$' fails: TestPhase12Threats/T-12-33; restored, cmp ok
RC-23 T-12-33 ../fonoteka.go/plugins/golem15/fonoteka/classes/album_queries.go ScopedAlbums(...) in FindAccessibleAlbum an unscoped album query go test ./plugins/golem15/fonoteka -run '^TestPhase12Threats$/^T-12-33$' fails: TestPhase12Threats/T-12-33; restored, cmp ok
RC-24 T-12-SC scripts/check-phase12.sh (mutated copy) the golang.org/x/image lookup in module_pin a fixed audited line bash <copy> --self-test fails: "refuse: self-test module_pin accepted golang.org/x/image v0.45.0"; original untouched
RC-25 T-12-27 scripts/check-phase12.sh (mutated copy) sys.exit(1) after a corpus secret is found pass bash <copy> --self-test fails: "refuse: self-test corpus_scan accepted a planted secret"; original untouched

Not every protection is removable on its own. Dropping AlbumsAccessibleBy from ScopedAlbums (RC-04) leaves the stale-moved, mis-scoped and soft-deleted cases passing, because the active-collection filter and GORM's own deleted_at filter still exclude them; only the token pin and the resolve/removal race depend on the access scope, and those cases fail. Making collection_id fillable in AlbumFillFields alone does not survive to the database either (setAlbumField has no setter for it and CreateAlbum sets the column), so RC-18 removes the server-set assignment instead.

Fixes made during the review

Defect Threat Fix Failing-when-broken test Commit
A stored photo whose original is missing, undecodable or declares more than 4096x4096 pixels made attach.File.Thumb return an error, so every listing that shows it (GET collections, the collection, the album) answered 500 from then on: a 100-byte PNG from any household member broke the household's listings T-12-16 Thumb follows WinterCMS's File::makeThumb catch branch: log the reason, store WinterCMS's broken-image picture (attach.BrokenImagePNG) as the thumbnail and return its URL TestThumbBrokenSourceServesPlaceholder (RED: three errors), TestPhase12Threats/T-12-16 summercms.go 1307060
in compared array elements loosely and not_in failed an array when any element was listed or when the array rule was absent; Laravel uses array_diff (exact strings) and validateNotIn is !validateIn T-12-14 validateIn/validateNotIn follow Laravel TestValidateRulesMatchLaravel (162 cases recorded from WinterCMS's validator; RED: three divergences) summercms.go 36983bc
JSON floats were cast to strings with up to 17 digits; PHP's (string) uses 14 (%.14G) T-12-14 phpFloatString formats like zend_gcvt TestPHPFloatStringMatchesPHPCast (RED: nine values) summercms.go 3ac1d64
(int) of request values read digits only and overflowed ("1e2" was 1, "9999999999999999999" was PHP_INT_MIN, out-of-range floats undefined); is_numeric("1e400") was false; float strings used Go's %G — (API-02 parity) phpIntCast follows zval_get_long (numeric prefix, saturating) and zend_dval_to_lval (modular), phpNumericValue accepts overflowing exponents, phpStringOf uses %.14G TestPHPValueCasts (RED: nineteen values) fonoteka.go 64f5496
The Laravel e-mail rule's domain part refused literals with spaces, allowed 63-byte labels after the first (egulias counts the dot), had no 253-byte cap and refused comments at the start of the domain — (API-01 parity, not observable: every invitation e-mail failure answers the same 500 page) laravelDomainOK follows egulias TestValidLaravelEmailRFC (RED: three addresses) fonoteka.go 817867d
albums/sync?per_page=1e2 paged one album where PHP pages 100 (phpInt read digits only); $request->boolean() did not trim and refused 1.0 — (API-02 parity) phpInt uses classes.PHPIntCast; laravelBoolean follows FILTER_VALIDATE_BOOLEAN TestRequestCasts (RED: six values) fonoteka.go 2869747