bridgebench 3.1.0-alpha.0 → 3.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +141 -173
  2. package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
  3. package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
  4. package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
  5. package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
  6. package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
  7. package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
  8. package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
  9. package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
  10. package/dist/cli.cjs +51 -49
  11. package/dist/cli.js +9 -7
  12. package/dist/client.cjs +5 -6
  13. package/dist/client.d.cts +3 -3
  14. package/dist/client.d.ts +3 -3
  15. package/dist/client.js +4 -5
  16. package/dist/contracts/index.cjs +6 -3
  17. package/dist/contracts/index.d.cts +2 -2
  18. package/dist/contracts/index.d.ts +2 -2
  19. package/dist/contracts/index.js +7 -4
  20. package/dist/index.cjs +8 -5
  21. package/dist/index.d.cts +9 -8
  22. package/dist/index.d.ts +9 -8
  23. package/dist/index.js +8 -5
  24. package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
  25. package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
  26. package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
  27. package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
  28. package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
  29. package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
  30. package/dist/tasks.cjs +3 -4
  31. package/dist/tasks.d.cts +1 -1
  32. package/dist/tasks.d.ts +1 -1
  33. package/dist/tasks.js +2 -3
  34. package/docs/README.md +32 -12
  35. package/docs/methodology.md +14 -0
  36. package/docs/operator-guide.md +178 -0
  37. package/docs/replay-elo.md +12 -2
  38. package/docs/reviewing-bridgebench.md +173 -0
  39. package/docs/task-authoring.md +79 -1
  40. package/package.json +5 -3
  41. package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
  42. package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
  43. package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
  44. package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
  45. package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
  46. package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
  47. package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
  48. package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
  49. package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
  50. package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
  51. package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
  52. package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
  53. package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
  54. package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
  55. package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
  56. package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
  57. package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
  58. package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
  59. package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
  60. package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
  61. package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
  62. package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
  63. package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
  64. package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
  65. package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
  66. package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
  67. package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
  68. package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
  69. package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
  70. package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
  71. package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
  72. package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
  73. package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
  74. package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
  75. package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
  76. package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
  77. package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
  78. package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
  79. package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
  80. package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
  81. package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
  82. package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
  83. package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
  84. package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
  85. package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
  86. package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
  87. package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
  88. package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
  89. package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
  90. package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
  91. package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
  92. package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
  93. package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
  94. package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
  95. package/tasks/security/public/taint-export-template-render.yaml +207 -0
  96. package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
  97. package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
  98. package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
  99. package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
  100. package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
  101. package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
  102. package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
  103. package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
  104. package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
  105. package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
  106. package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
  107. package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
  108. package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
  109. package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
  110. package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
  111. package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
  112. package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
  113. package/dist/chunk-4TWPCPRP.cjs.map +0 -1
  114. package/dist/chunk-7YCJSOK7.cjs.map +0 -1
  115. package/dist/chunk-CIXITJW6.cjs.map +0 -1
  116. package/dist/chunk-EQHRUV2I.js.map +0 -1
  117. package/dist/chunk-JTVNKSMO.js.map +0 -1
  118. package/dist/chunk-LFKEV2YL.js.map +0 -1
  119. package/dist/chunk-NJTYVNP4.cjs.map +0 -1
  120. package/dist/chunk-UECBSKTD.js.map +0 -1
  121. package/dist/cli.cjs.map +0 -1
  122. package/dist/cli.js.map +0 -1
  123. package/dist/client.cjs.map +0 -1
  124. package/dist/client.js.map +0 -1
  125. package/dist/contracts/index.cjs.map +0 -1
  126. package/dist/contracts/index.js.map +0 -1
  127. package/dist/index.cjs.map +0 -1
  128. package/dist/index.js.map +0 -1
  129. package/dist/tasks.cjs.map +0 -1
  130. package/dist/tasks.js.map +0 -1
@@ -0,0 +1,228 @@
1
+ id: authz-object-scope-idor
2
+ version: 1.0.0
3
+ category: security
4
+ cluster: authz-boundary
5
+ difficulty: expert
6
+ title: Vellum Multi-Tenant Document API — Object-Scope Isolation Analysis
7
+ summary: >-
8
+ In the multi-tenant "Vellum" document API, every by-id read of an org-owned row
9
+ must carry an org_id predicate. Several by-id endpoints look suspicious; some are
10
+ scoped in a service layer, some are cross-org by design. Exactly one is a real
11
+ cross-tenant IDOR. Classify five endpoints, name the single defect, and locate
12
+ the fix — using only the artifacts.
13
+ prompt: |
14
+ You are reviewing tenant isolation on "Vellum," a multi-tenant document API.
15
+ Rows in documents/folders belong to one organization via an org_id column, and
16
+ the caller's organization arrives as ctx.orgId. You are given seven artifacts:
17
+ documents-controller, folders-controller, document-service, shares-service,
18
+ db-queries, tenancy-spec, and design-note.
19
+
20
+ Answer each item using ONLY the supplied artifacts. Cite the artifact id(s) you
21
+ rely on for every claim. If something is not defined/reachable/exploitable in
22
+ the artifacts, say so explicitly. Do NOT write exploit code, requests, or
23
+ enumeration payloads — this is an analysis-only task.
24
+
25
+ For items 1–5, classify each endpoint as exactly one of: PROPERLY-SCOPED (tenant
26
+ isolation enforced), CROSS-TENANT IDOR (an org-owned row is loaded without an
27
+ org predicate), or INTENDED CROSS-ORG / GLOBAL (unscoped access is by design).
28
+ Justify each answer by citing the query's actual WHERE predicate (db-queries),
29
+ the code path that builds it, and the relevant tenancy rule or design decision.
30
+
31
+ 1. GET /documents/:id (DocumentsController.getDocument)
32
+ 2. GET /folders/:id (FoldersController.getFolder)
33
+ 3. GET /shares/:token (SharesService.resolveShare)
34
+ 4. GET /templates/:id (template catalog read)
35
+ 5. PATCH /documents/:id (DocumentsController.updateDocument) — is this a
36
+ broken-tenant-isolation or a mass-assignment defect, or neither?
37
+
38
+ 6. Exactly one endpoint is a genuine cross-tenant IDOR (broken object-level
39
+ authorization). Name that single endpoint and state the precondition an
40
+ attacker needs for it to be exploitable. Ground the claim in the query
41
+ predicate and the tenancy rule it violates.
42
+
43
+ 7. State where and how to fix the IDOR from item 6 — the exact predicate to add
44
+ and the layer it belongs in — and explain why a fix at that layer is correct
45
+ while a controller-level check is not the right place.
46
+ artifacts:
47
+ - id: documents-controller
48
+ type: code
49
+ label: vellum-api/src/documents/documents.controller.ts
50
+ content: |
51
+ // Every route requires a valid session (global AuthGuard) and receives the
52
+ // caller's tenant context as `ctx` (ctx.orgId = the caller's organization).
53
+ // This file lists every route on the /documents controller.
54
+
55
+ @Controller('documents')
56
+ @UseGuards(AuthGuard)
57
+ export class DocumentsController {
58
+ // Read a single document by id. For latency this reads through the
59
+ // repository DIRECTLY rather than DocumentService; the SQL it runs is
60
+ // db-queries q_get_document.
61
+ @Get(':id')
62
+ getDocument(@Ctx() ctx: TenantCtx, @Param('id') id: string) {
63
+ return this.documentsRepo.findById(id);
64
+ }
65
+
66
+ // List the caller's documents (through DocumentService).
67
+ @Get()
68
+ listDocuments(@Ctx() ctx: TenantCtx) {
69
+ return this.documentService.listDocuments(ctx);
70
+ }
71
+
72
+ // Update a document. The body is bound to UpdateDocumentDto, which is
73
+ // `strict`: only `title` and `body` are accepted; unknown keys (including
74
+ // org_id) are stripped and rejected. Persists via DocumentService.
75
+ @Patch(':id')
76
+ updateDocument(
77
+ @Ctx() ctx: TenantCtx,
78
+ @Param('id') id: string,
79
+ @Body() dto: UpdateDocumentDto,
80
+ ) {
81
+ return this.documentService.updateDocument(ctx, id, dto);
82
+ }
83
+
84
+ // Soft-delete a document (through DocumentService).
85
+ @Delete(':id')
86
+ deleteDocument(@Ctx() ctx: TenantCtx, @Param('id') id: string) {
87
+ return this.documentService.deleteDocument(ctx, id);
88
+ }
89
+ }
90
+ - id: folders-controller
91
+ type: code
92
+ label: vellum-api/src/folders/folders.controller.ts
93
+ content: |
94
+ // This file lists every route on the /folders controller. Note the
95
+ // controller does not itself add any tenant filter — folder scoping lives in
96
+ // DocumentService (see document-service).
97
+
98
+ @Controller('folders')
99
+ @UseGuards(AuthGuard)
100
+ export class FoldersController {
101
+ @Get(':id')
102
+ getFolder(@Ctx() ctx: TenantCtx, @Param('id') id: string) {
103
+ return this.documentService.getFolder(ctx, id);
104
+ }
105
+
106
+ @Get(':id/documents')
107
+ listFolderDocuments(@Ctx() ctx: TenantCtx, @Param('id') id: string) {
108
+ return this.documentService.listFolderDocuments(ctx, id);
109
+ }
110
+ }
111
+ - id: document-service
112
+ type: code
113
+ label: vellum-api/src/documents/document.service.ts
114
+ content: |
115
+ // Tenant scoping for folder reads, list reads, updates, and deletes lives
116
+ // HERE: each method injects ctx.orgId into the query it builds. The raw SQL
117
+ // for each is catalogued in db-queries.
118
+
119
+ @Injectable()
120
+ export class DocumentService {
121
+ getFolder(ctx: TenantCtx, id: string) {
122
+ // q_get_folder: WHERE id = :id AND org_id = :orgId
123
+ return this.foldersRepo.findScoped(id, ctx.orgId);
124
+ }
125
+
126
+ listFolderDocuments(ctx: TenantCtx, folderId: string) {
127
+ // q_list_folder_docs: WHERE folder_id = :id AND org_id = :orgId
128
+ return this.documentsRepo.findByFolderScoped(folderId, ctx.orgId);
129
+ }
130
+
131
+ listDocuments(ctx: TenantCtx) {
132
+ // q_list_documents: WHERE org_id = :orgId AND deleted_at IS NULL
133
+ return this.documentsRepo.findAllScoped(ctx.orgId);
134
+ }
135
+
136
+ updateDocument(ctx: TenantCtx, id: string, dto: UpdateDocumentDto) {
137
+ // q_update_document: WHERE id = :id AND org_id = :orgId
138
+ return this.documentsRepo.updateScoped(id, ctx.orgId, dto);
139
+ }
140
+
141
+ deleteDocument(ctx: TenantCtx, id: string) {
142
+ // q_delete_document: WHERE id = :id AND org_id = :orgId
143
+ return this.documentsRepo.softDeleteScoped(id, ctx.orgId);
144
+ }
145
+
146
+ // NOTE: single-document-by-id reads are NOT served here.
147
+ // DocumentsController.getDocument calls documentsRepo.findById(id)
148
+ // directly (see documents-controller), bypassing this scoping layer.
149
+ }
150
+ - id: shares-service
151
+ type: code
152
+ label: vellum-api/src/shares/shares.service.ts
153
+ content: |
154
+ // Public share links. A document owner mints an unguessable 256-bit token;
155
+ // anyone holding the token — INCLUDING users in other organizations — may
156
+ // read the shared document. This cross-org access is intentional (design-note).
157
+ // GET /shares/:token routes straight to resolveShare().
158
+
159
+ @Injectable()
160
+ export class SharesService {
161
+ // q_get_share: WHERE share_token = :token AND revoked_at IS NULL
162
+ resolveShare(token: string) {
163
+ return this.sharesRepo.findByToken(token);
164
+ }
165
+ }
166
+ - id: db-queries
167
+ type: table
168
+ label: Vellum data-access catalog (per-endpoint WHERE predicates — complete)
169
+ content: |
170
+ The WHERE predicate of every by-id / by-token read and write in Vellum. This
171
+ catalog is COMPLETE: every endpoint that loads or mutates a row by an
172
+ identifier appears here with the EXACT predicate its query uses. If a
173
+ predicate is not shown for an endpoint, that endpoint does not exist.
174
+
175
+ endpoint | query id | table | WHERE predicate
176
+ -----------------------------|--------------------|-----------|----------------------------------------------
177
+ GET /documents/:id | q_get_document | documents | id = :id
178
+ GET /documents | q_list_documents | documents | org_id = :orgId AND deleted_at IS NULL
179
+ PATCH /documents/:id | q_update_document | documents | id = :id AND org_id = :orgId
180
+ DELETE /documents/:id | q_delete_document | documents | id = :id AND org_id = :orgId
181
+ GET /folders/:id | q_get_folder | folders | id = :id AND org_id = :orgId
182
+ GET /folders/:id/documents| q_list_folder_docs | documents | folder_id = :id AND org_id = :orgId
183
+ GET /shares/:token | q_get_share | shares | share_token = :token AND revoked_at IS NULL
184
+ GET /templates/:id | q_get_template | templates | id = :id
185
+ - id: tenancy-spec
186
+ type: spec
187
+ label: Vellum multi-tenancy rules (authoritative)
188
+ content: |
189
+ Authoritative tenant-isolation rules.
190
+
191
+ 1. Every row in `documents`, `folders`, and `document_versions` belongs to
192
+ exactly one organization via its `org_id` column. A caller in org A must
193
+ never read, update, or delete a row whose org_id != A.
194
+
195
+ 2. Therefore every query that loads such a row by its primary key MUST include
196
+ `AND org_id = :orgId`, where :orgId is the caller's organization from the
197
+ tenant context. A by-id read of an org-owned row that omits this predicate
198
+ is a cross-tenant isolation defect (IDOR): document ids are UUIDv7 and
199
+ appear in URLs, export bundles, and webhook payloads, so an id belonging to
200
+ org B is readily observable by a user in org A.
201
+
202
+ 3. EXCEPTIONS — rows that are intentionally NOT org-scoped:
203
+ - `templates`: a single GLOBAL catalog shared by all organizations.
204
+ Template rows have no org_id column; GET /templates/:id is global by
205
+ design and correctly omits an org predicate.
206
+ - Share links (`shares`): access is granted by possession of an unguessable
207
+ 256-bit `share_token`, not by org membership. A cross-org read via a
208
+ valid token is an intended product feature (see design-note).
209
+
210
+ 4. Write endpoints MUST bind the request body to a strict DTO that whitelists
211
+ updatable columns. `org_id` is never an updatable column.
212
+ - id: design-note
213
+ type: note
214
+ label: Vellum design notes — cross-org sharing and the update path
215
+ content: |
216
+ Share links are meant to cross tenant boundaries. When a document owner
217
+ creates a share we mint a 256-bit random token; anyone holding the link can
218
+ read that one document regardless of their organization. Enumeration is not a
219
+ concern: the token space is unguessable and resolveShare() looks up STRICTLY
220
+ by token (db-queries q_get_share), returning nothing for an unknown or revoked
221
+ token. This is a feature, not a leak.
222
+
223
+ The update path (PATCH /documents/:id) once accepted a raw body, which looked
224
+ like a mass-assignment risk. It now binds to UpdateDocumentDto, which
225
+ whitelists only `title` and `body`; unknown fields (including org_id) are
226
+ stripped and rejected. Combined with the org predicate on q_update_document
227
+ (id AND org_id), a caller can only ever update documents in their own org.
228
+ tags: [authz, idor, multi-tenant, tenant-isolation, authz-boundary, security]
@@ -0,0 +1,226 @@
1
+ id: patch-mass-assignment-privesc
2
+ version: 1.0.0
3
+ category: security
4
+ cluster: patch-verification
5
+ difficulty: expert
6
+ title: Wicket Accounts — Verifying Five Candidate Mass-Assignment Privilege-Escalation Fixes
7
+ summary: >-
8
+ The Wicket accounts profile-update handler trusts `body.role` AND spreads nested
9
+ `body.metadata` onto the account row, allowing self privilege escalation. Five
10
+ competing candidate patches are submitted. Decide which actually close BOTH write
11
+ paths and which are incomplete, cosmetic, or only provable by reading the
12
+ editable-fields allowlist — analysis only.
13
+ prompt: |
14
+ You are reviewing candidate fixes for a reported mass-assignment privilege-escalation
15
+ finding in the "Wicket" accounts service. The handler PATCH /accounts/me lets a caller
16
+ update their own profile, but it writes `req.body.role` directly onto the account row
17
+ AND spreads `req.body.metadata` onto the same row, so a role supplied either directly
18
+ or nested under metadata reaches the privileged `role` column. Five engineers submitted
19
+ competing patches (A, B, C, D, E); exactly one will be merged.
20
+
21
+ You are given six artifacts: update-handler (the vulnerable handler), editable-fields
22
+ (the allowlist constant defining which columns a self-service update may write),
23
+ patches-abc (candidate patches A, B, C), patches-de (candidate patches D, E), role-model
24
+ (the authoritative spec on how `role` is stored and every position that can write it),
25
+ and vuln-report (the finding).
26
+
27
+ Answer each deliverable using ONLY the supplied artifacts. Cite the artifact id(s) you
28
+ rely on for every claim. If a fact needed to judge a patch lives in another artifact,
29
+ OPEN that artifact and cite it — do not guess. This is analysis only: reason about which
30
+ write positions each patch neutralizes; do NOT write exploit requests or payloads.
31
+
32
+ Definitions you must use:
33
+ - role-model is authoritative: it enumerates EXACTLY the code positions in this handler
34
+ that can write `user.role`. A patch CLOSES the finding only if it neutralizes BOTH
35
+ declared write positions.
36
+ - editable-fields is the source of truth for which fields a profile update may write.
37
+ - A patch DOES-NOT-CLOSE if any declared write position for `role` still reaches the
38
+ row, or if it changes a layer (e.g. the response) that does not affect the write.
39
+
40
+ Deliverables:
41
+
42
+ 1. Patch A (patches-abc): verdict CLOSES or DOES-NOT-CLOSE, with the specific reason.
43
+ Which declared write positions does it neutralize?
44
+
45
+ 2. Patch B (patches-abc): verdict and the specific gap. Does it neutralize BOTH declared
46
+ write positions, or does one path still reach `role`?
47
+
48
+ 3. Patch C (patches-abc): verdict and the specific gap. Consider what values survive its
49
+ check, using role-model.
50
+
51
+ 4. Patch D (patches-de): verdict. Patch D uses `Object.assign(user, pick(req.body,
52
+ EDITABLE_FIELDS))`. State whether D is safe, unsafe, or unverifiable from the
53
+ artifacts, and name the EXACT artifact you must open to decide. Justify with what
54
+ that artifact contains.
55
+
56
+ 5. Patch E (patches-de): verdict and the specific gap. Which layer does it change, and
57
+ does that layer affect the write path?
58
+
59
+ 6. Recommend which single patch to merge and state any residual work still required
60
+ after merging it (for example, a declared write position a different patch would have
61
+ left open). Justify from role-model and editable-fields.
62
+ artifacts:
63
+ - id: update-handler
64
+ type: code
65
+ label: Wicket profile-update handler (vulnerable original)
66
+ content: |
67
+ // wicket/accounts/update-profile.ts — PATCH /accounts/me (Wicket accounts service).
68
+ // Self-service profile update for the logged-in caller.
69
+
70
+ export async function updateProfile(req: Request, res: Response) {
71
+ const user = await accounts.findById(req.session.userId);
72
+
73
+ // Apply the caller's changes.
74
+ user.displayName = req.body.displayName ?? user.displayName;
75
+ user.role = req.body.role ?? user.role; // WRITE POSITION 1: direct role assign
76
+ Object.assign(user, req.body.metadata ?? {}); // WRITE POSITION 2: spreads nested metadata
77
+
78
+ await accounts.save(user);
79
+ return res.json({ id: user.id, displayName: user.displayName, role: user.role });
80
+ }
81
+ - id: editable-fields
82
+ type: code
83
+ label: EDITABLE_FIELDS allowlist constant (source of truth)
84
+ content: |
85
+ // wicket/accounts/editable-fields.ts
86
+ // Single source of truth for which account columns a self-service profile update
87
+ // may write. Anything NOT listed here is server-controlled and must never be set
88
+ // from request input.
89
+
90
+ export const EDITABLE_FIELDS = [
91
+ 'displayName',
92
+ 'bio',
93
+ 'avatarUrl',
94
+ 'locale',
95
+ ] as const;
96
+
97
+ // NOTE: 'role' is intentionally EXCLUDED — role changes go through the admin grant
98
+ // flow only (see role-model). 'metadata' is also excluded: it is not a persisted
99
+ // column, so it is never a valid write target for a profile update.
100
+ - id: role-model
101
+ type: spec
102
+ label: Wicket role model — authoritative role storage and write-position inventory
103
+ content: |
104
+ Wicket role model — AUTHORITATIVE.
105
+
106
+ Storage:
107
+ - Every account row has a single top-level column `role` (string). Its legal
108
+ values are exactly ROLE_ENUM = ['member', 'billing', 'support', 'admin'].
109
+ - `role` is PRIVILEGED and server-controlled. In the self-service profile-update
110
+ path it must NEVER be writable from request input.
111
+ - The ONLY legitimate way to change a role is the admin grant flow
112
+ (POST /admin/roles), which is out of scope for updateProfile.
113
+
114
+ Why enum validation is insufficient:
115
+ - All four ROLE_ENUM values are syntactically valid role names. 'admin' is the
116
+ privileged one. Validating a submitted role against ROLE_ENUM therefore does NOT
117
+ prevent escalation — 'admin' passes the enum check and is still a self-assigned
118
+ privileged role.
119
+
120
+ Write-position inventory for updateProfile (EXHAUSTIVE):
121
+ - `user.role` can be written by EXACTLY TWO code positions in this handler:
122
+ WP1 — a direct assignment `user.role = <request input>`.
123
+ WP2 — any `Object.assign(user, X)` / object spread where X carries a `role`
124
+ key. In the original handler X is `req.body.metadata`, so a caller can
125
+ set `role` nested under `metadata` and reach the same column.
126
+ - There are NO other code positions in this handler that can write `role`.
127
+ - A fix CLOSES the mass-assignment finding ONLY if it neutralizes BOTH WP1 and WP2.
128
+ - `metadata` is not a persisted column and is not in EDITABLE_FIELDS; spreading it
129
+ onto the account row is itself the nested-assignment bug (WP2).
130
+ - id: patches-abc
131
+ type: diff
132
+ label: Candidate patches A, B, C (mutually exclusive alternatives)
133
+ content: |
134
+ Three competing candidate patches. Exactly ONE of A/B/C is applied — they are
135
+ alternatives, not stacked.
136
+
137
+ === Patch A: strict allowlist DTO (role and metadata rejected) ===
138
+ --- a/wicket/accounts/update-profile.ts
139
+ +++ b/wicket/accounts/update-profile.ts
140
+ @@ handler body
141
+ - user.displayName = req.body.displayName ?? user.displayName;
142
+ - user.role = req.body.role ?? user.role;
143
+ - Object.assign(user, req.body.metadata ?? {});
144
+ + // validateProfileDto returns ONLY { displayName?, bio?, avatarUrl?, locale? };
145
+ + // it strips/rejects any other key, including `role` and `metadata`.
146
+ + const dto = validateProfileDto(req.body);
147
+ + if (dto.displayName !== undefined) user.displayName = dto.displayName;
148
+ + if (dto.bio !== undefined) user.bio = dto.bio;
149
+ + if (dto.avatarUrl !== undefined) user.avatarUrl = dto.avatarUrl;
150
+ + if (dto.locale !== undefined) user.locale = dto.locale;
151
+
152
+ === Patch B: reject a direct body.role ===
153
+ --- a/wicket/accounts/update-profile.ts
154
+ +++ b/wicket/accounts/update-profile.ts
155
+ @@ handler body
156
+ + if (req.body.role !== undefined) {
157
+ + return res.status(400).json({ error: 'role is not editable' });
158
+ + }
159
+ user.displayName = req.body.displayName ?? user.displayName;
160
+ - user.role = req.body.role ?? user.role;
161
+ + // (direct role assignment removed)
162
+ Object.assign(user, req.body.metadata ?? {}); // metadata spread KEPT
163
+
164
+ === Patch C: validate body.role against the role enum ===
165
+ --- a/wicket/accounts/update-profile.ts
166
+ +++ b/wicket/accounts/update-profile.ts
167
+ @@ handler body
168
+ user.displayName = req.body.displayName ?? user.displayName;
169
+ - user.role = req.body.role ?? user.role;
170
+ + if (req.body.role !== undefined) {
171
+ + if (!ROLE_ENUM.includes(req.body.role)) {
172
+ + return res.status(400).json({ error: 'invalid role' });
173
+ + }
174
+ + user.role = req.body.role; // accepts ANY valid role name
175
+ + }
176
+ Object.assign(user, req.body.metadata ?? {}); // metadata spread KEPT
177
+ - id: patches-de
178
+ type: diff
179
+ label: Candidate patches D, E (mutually exclusive alternatives)
180
+ content: |
181
+ Two more competing candidate patches — alternatives to A/B/C, not stacked with them.
182
+
183
+ === Patch D: pick() over the EDITABLE_FIELDS allowlist ===
184
+ --- a/wicket/accounts/update-profile.ts
185
+ +++ b/wicket/accounts/update-profile.ts
186
+ @@ imports
187
+ +import { EDITABLE_FIELDS } from './editable-fields';
188
+ @@ handler body
189
+ - user.displayName = req.body.displayName ?? user.displayName;
190
+ - user.role = req.body.role ?? user.role;
191
+ - Object.assign(user, req.body.metadata ?? {});
192
+ + // pick() keeps ONLY keys present in EDITABLE_FIELDS; every other key of req.body
193
+ + // (role, metadata, and anything nested) is dropped before it can touch the row.
194
+ + Object.assign(user, pick(req.body, EDITABLE_FIELDS));
195
+
196
+ === Patch E: drop role from the response body ===
197
+ --- a/wicket/accounts/update-profile.ts
198
+ +++ b/wicket/accounts/update-profile.ts
199
+ @@ handler body
200
+ user.displayName = req.body.displayName ?? user.displayName;
201
+ user.role = req.body.role ?? user.role; // WRITE POSITION 1 unchanged
202
+ Object.assign(user, req.body.metadata ?? {}); // WRITE POSITION 2 unchanged
203
+
204
+ await accounts.save(user);
205
+ - return res.json({ id: user.id, displayName: user.displayName, role: user.role });
206
+ + return res.json({ id: user.id, displayName: user.displayName }); // role not echoed
207
+ - id: vuln-report
208
+ type: note
209
+ label: Wicket security advisory WICKET-2026-014 (internal)
210
+ content: |
211
+ Advisory WICKET-2026-014 (internal, fictional).
212
+
213
+ A reporter found that PATCH /accounts/me lets a caller escalate their OWN role. Two
214
+ observations:
215
+ (1) the handler copies `req.body.role` straight onto the account row (a direct
216
+ write position);
217
+ (2) the handler ALSO spreads `req.body.metadata` onto the row, so a `role` nested
218
+ under metadata reaches the same column (a second, nested write position).
219
+
220
+ Request to the reviewer: verify that each candidate patch actually closes BOTH write
221
+ positions, not just the obvious direct one. Authoritative details on how `role` is
222
+ stored and the exhaustive list of write positions are in role-model; the
223
+ editable-fields constant is the source of truth for what a profile update may write.
224
+ To judge a patch that relies on that constant, OPEN editable-fields and confirm what
225
+ it contains rather than assuming.
226
+ tags: [security, patch-verification, mass-assignment, privilege-escalation, allowlist]