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.
- package/README.md +141 -173
- package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
- package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
- package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
- package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
- package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
- package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
- package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
- package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
- package/dist/cli.cjs +51 -49
- package/dist/cli.js +9 -7
- package/dist/client.cjs +5 -6
- package/dist/client.d.cts +3 -3
- package/dist/client.d.ts +3 -3
- package/dist/client.js +4 -5
- package/dist/contracts/index.cjs +6 -3
- package/dist/contracts/index.d.cts +2 -2
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +7 -4
- package/dist/index.cjs +8 -5
- package/dist/index.d.cts +9 -8
- package/dist/index.d.ts +9 -8
- package/dist/index.js +8 -5
- package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
- package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
- package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
- package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
- package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
- package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
- package/dist/tasks.cjs +3 -4
- package/dist/tasks.d.cts +1 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +2 -3
- package/docs/README.md +32 -12
- package/docs/methodology.md +14 -0
- package/docs/operator-guide.md +178 -0
- package/docs/replay-elo.md +12 -2
- package/docs/reviewing-bridgebench.md +173 -0
- package/docs/task-authoring.md +79 -1
- package/package.json +5 -3
- package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
- package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
- package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
- package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
- package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
- package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
- package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
- package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
- package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
- package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
- package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
- package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
- package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
- package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
- package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
- package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
- package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
- package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
- package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
- package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
- package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
- package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
- package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
- package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
- package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
- package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
- package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
- package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
- package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
- package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
- package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
- package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
- package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
- package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
- package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
- package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
- package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
- package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
- package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
- package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
- package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
- package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
- package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
- package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
- package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
- package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
- package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
- package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
- package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
- package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
- package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
- package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
- package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
- package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
- package/tasks/security/public/taint-export-template-render.yaml +207 -0
- package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
- package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
- package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
- package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
- package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
- package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
- package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
- package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
- package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
- package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
- package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
- package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
- package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
- package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
- package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
- package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
- package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
- package/dist/chunk-4TWPCPRP.cjs.map +0 -1
- package/dist/chunk-7YCJSOK7.cjs.map +0 -1
- package/dist/chunk-CIXITJW6.cjs.map +0 -1
- package/dist/chunk-EQHRUV2I.js.map +0 -1
- package/dist/chunk-JTVNKSMO.js.map +0 -1
- package/dist/chunk-LFKEV2YL.js.map +0 -1
- package/dist/chunk-NJTYVNP4.cjs.map +0 -1
- package/dist/chunk-UECBSKTD.js.map +0 -1
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.cjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/contracts/index.cjs.map +0 -1
- package/dist/contracts/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tasks.cjs.map +0 -1
- 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]
|