Files
summercms/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-SECURITY-REVIEW.md
Jakub Zych 13887ee0b1 docs(03-04): record Phase 3 validation and security evidence
Map T-03-01 through T-03-SC to passing tests, record the check-phase3.sh
gate, and mark nyquist_compliant after that gate exited 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 20:39:53 +02:00

9.2 KiB

phase, slug, status, threats_open, asvs_level, created, verified
phase slug status threats_open asvs_level created verified
03 first-vertical-slice-genres-end-to-end verified 0 1 2026-09-17 2026-09-17

Phase 3 — Security Review

Token verification, cross-plugin guard lookup, migration isolation, query tenant boundaries, and secret handling for GET /_fonoteka/api/v1/genres.


Trust Boundaries

Boundary Description Data Crossing
Config and Go module resolution → process DSN, JWT secret, and dependency metadata enter boot SUMMER_DATABASE__DSN, SUMMER_GOLEM15__USER__JWT__SECRET, go.mod
Bearer token → user lookup → handler Untrusted JWT claims become authenticated context Authorization header, users.id, MustChangePassword
Plugin declarations → ServeMux Named middleware and group routes compile to stdlib mux jwt.auth, inv.must-change-password, path params
Authenticated user and context → SQL Active collection and album counts must stay tenant-scoped owner_id, editor rows, collection_id, genre_id
CLI plugin argument → migration history Rollback must not select another plugin's gormigrate table --plugin, history table name
Recorded fixture → test request JWT and colliding IDs come from trusted seed state jwt:alice, id:genre / id:token / id:wishlist-album

Threat Register

Threat ID Category Severity Component Disposition Mitigation Status
T-03-01 Tampering high migrations / locale / rollback mitigate Explicit DDL, per-plugin history tables, ICU pl-PL check before migrate, isolated RollbackLast closed
T-03-02 Elevation of privilege high named middleware mitigate Missing names fail boot with plugin+name; unauthenticated requests never reach the handler; seven-stage order closed
T-03-03 Spoofing high JWT guard mitigate HS256 pinned, exp+sub required, persisted user lookup, empty secret fails boot, 401 bodies omit secrets closed
T-03-04 Information disclosure high aggregate query mitigate Grouped owner OR editor, AND active collection; foreign albums stay at count 0 closed
T-03-05 Tampering medium lagoon.OrderBy / non_empty mitigate Allow-listed identifiers/direction, no COLLATE, non_empty in 0|1 else 422 closed
T-03-06 Tampering high parity acceptance mitigate Unmodified fixture, passing increments only after replay, mutated body fails, 153 pending never count as pass closed
T-03-07 Information disclosure medium typed route IDs mitigate Malformed and unknown IDs both 404; constraints compiled at registration closed
T-03-SC Tampering medium Go module resolution mitigate Official module paths already in go.mod; testcontainers added only as the named STACK test dependency closed

Status: open · closed Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)


Findings by Threat

T-03-01 — schema drift / locale / rollback isolation

  • Source: lagoon/connection.go (Open/Use/CheckLocale), lagoon/migrations.go (HistoryTableName, Migrate, RollbackLast), Fonoteka plugin updates (explicit CREATE TABLE, no AutoMigrate).
  • Test evidence: TestWrongICULocaleFailsOpen, TestTwoPluginMigrationSetsIsolated, TestNoAutoMigrate, app TestMigrateSeedsCanonicalGenres, TestRollbackLastIsolatesFonoteka, TestPluginMigrationsDoNotUseAutoMigrate, TestGenreQueryFailsOnWrongLocale.
  • Finding: ICU pl-PL is required before GORM is used. Two plugins keep separate summer_migrations_* tables; rolling back demo.beta (framework) or golem15.fonoteka (app) leaves the other plugin's history and rows intact. Production code and plugin sources contain no AutoMigrate.
  • Disposition: closed / mitigate.

T-03-02 — missing or bypassed named guard

  • Source: surf/router.go (wrap fails on unknown names; compile wraps recover → CORS around the mux; named auth runs after locale and before org/rate/handler), plugins/golem15/fonoteka/plugin.go (Use("jwt.auth", "inv.must-change-password")).
  • Test evidence: TestAssembleMissingMiddlewareFailsBoot, TestUnauthenticatedNamedGuardDoesNotReachHandler, TestPipelineOrderRecoverCORSLocaleAuthPasswordOrgRateHandler, TestCORSPreflightBypassesNamedAuth, app TestGenreSecurityBoundaries/unauthenticated and /cors-preflight.
  • Finding: A missing jwt.auth name fails Assemble with plugin ID and name. OPTIONS preflight returns 204 without running named auth or the genre handler (Cache-Control stays unset). GET without a token returns 401 Token not provided and does not set the handler's Cache-Control: no-cache, private.
  • Disposition: closed / mitigate.

T-03-03 — JWT forgery and secret handling

  • Source: bouncer/jwt.go (WithValidMethods([]string{"HS256"}), WithExpirationRequired(), nonempty sub, UserProvider.FindByID), plugins/golem15/user/user.go (jwtSecret fails closed).
  • Test evidence: TestVerifyRejectsBadTokens, TestVerifyRejectsAlgNoneEmptySecretAndAbsentExp, TestVerifyAndMiddlewareOmitTokenAndSecret, TestMiddlewareStatusBodies, app TestGenreSecurityBoundaries (malformed / expired / wrong HMAC / alg:none / bad signature / absent exp / absent sub / unknown user), TestEmptyJWTSecretFailsBoot, TestGenreListBehindJWT.
  • Finding: alg:none, HS384, bad signatures, missing/expired exp, missing sub, and unknown subjects never reach the handler. Empty golem15.user.jwt.secret fails Boot. 401 JSON bodies are PHP-shaped and do not echo the token or secret.
  • Disposition: closed / mitigate.

T-03-04 — cross-tenant album counts

  • Source: plugins/golem15/fonoteka/active_collection.go (AccessibleByMembership grouped OR, then AND collection_id), genre_handler.go left-join count.
  • Test evidence: TestGenreCountsScopedToActiveCollection, TestGenreSecurityBoundaries/alice-counts-ignore-foreign, /bob-counts-ignore-alice.
  • Finding: Alice's rock albums do not appear in Bob's jazz counts and vice versa. Invalid stored context falls back to the lowest-ID accessible real collection. Wishlist and foreign collections are not counted.
  • Disposition: closed / mitigate.

T-03-05 — ORDER BY / non_empty injection

  • Source: lagoon/order.go allow-list, genre_handler.go parseNonEmpty.
  • Test evidence: TestOrderClauseAllowList, integration non_empty=0|1|invalid and duplicate-key last-wins, 422 envelope.
  • Finding: Unknown columns and directions are rejected. No COLLATE is emitted. Invalid non_empty returns the PHP 422 envelope.
  • Disposition: closed / mitigate.

T-03-06 — false-green parity

  • Source: parity/parity_test.go (runCorpusRoute increments passing only after ported replay), parity/manifest.yaml (one ported route with seed_hook: genres), unmodified fixtures/routes/get_genres_jwt.yaml.
  • Test evidence: TestParityCorpus coverage subtest (154 recorded, 1 passing, 153 pending, 0 failing, 0 unrecorded), TestParityContract/honest-counts, ported-mismatch, ported-mutated-response.
  • Finding: Pending routes are not sent to the Go handler. A mutated album_count fails ReplayFlow. The recorded PHP fixture still contains Bearer {{jwt:alice}} and "album_count":0.
  • Disposition: closed / mitigate.

T-03-07 — typed ID oracle

  • Source: surf/params.go (IntParam, constrain), examples/hello/plugins/greeter/plugin.go.
  • Test evidence: TestTypedIDRouteReturns404, TestWhereInRejectsOutsideEnum, TestTypedItemRoute.
  • Finding: /items/nope and /items/99 both 404; /kinds/other 404. Request text never builds a regex or SQL fragment.
  • Disposition: closed / mitigate.

T-03-SC — module path legitimacy

  • Source: framework go.mod (GORM, pgx, gormigrate, golang-jwt, testcontainers at STACK versions).
  • Test evidence: go vet ./... / go test ./... in both modules; slopcheck false positives on recent versions documented in 03-RESEARCH.md.
  • Finding: No unofficial module path was added. testcontainers is the STACK-named test dependency used by lagoon isolation tests and the existing app TestMain.
  • Disposition: closed / mitigate.

Accepted Risks Log

No accepted risks.

High-severity JWT, missing-guard, cross-tenant, migration-isolation, and false-green parity issues are mitigated with failing-when-broken tests. Token issuing remains test-only (Phase 7). Full CORS/locale/rate-limit depth remains Phase 6. Accented/punctuation Polish collation vs MariaDB is documented as a later fixture risk (T-03-05 residual, medium, not open).


Security Audit Trail

Audit Date Threats Total Closed Open Run By
2026-09-17 8 8 0 gsd-executor (03-04)

Sign-Off

  • All threats have a disposition (mitigate / accept / transfer)
  • Accepted risks documented in Accepted Risks Log
  • threats_open: 0 confirmed
  • status: verified set in frontmatter
  • No open high-severity JWT or cross-tenant issue remains

Approval: verified 2026-09-17