payload-doctor 0.5.4
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/CHANGELOG.md +279 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/SKILL.md +50 -0
- package/dist/checks/access.js +303 -0
- package/dist/checks/config.js +444 -0
- package/dist/checks/index.js +15 -0
- package/dist/checks/project.js +235 -0
- package/dist/checks/quality.js +106 -0
- package/dist/checks/rendering.js +57 -0
- package/dist/checks/routes.js +114 -0
- package/dist/cli.js +256 -0
- package/dist/fix.js +38 -0
- package/dist/report.js +171 -0
- package/dist/score.js +19 -0
- package/dist/suppress.js +76 -0
- package/dist/types.js +2 -0
- package/dist/util.js +281 -0
- package/dist/version.js +5 -0
- package/package.json +54 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.accessChecks = void 0;
|
|
4
|
+
const ts_morph_1 = require("ts-morph");
|
|
5
|
+
const util_1 = require("../util");
|
|
6
|
+
const PRIVILEGED_WORDS = new Set([
|
|
7
|
+
'role',
|
|
8
|
+
'roles',
|
|
9
|
+
'admin',
|
|
10
|
+
'isadmin',
|
|
11
|
+
'staff',
|
|
12
|
+
'isstaff',
|
|
13
|
+
'permission',
|
|
14
|
+
'permissions',
|
|
15
|
+
'capability',
|
|
16
|
+
'capabilities',
|
|
17
|
+
]);
|
|
18
|
+
/**
|
|
19
|
+
* The headline rule: Payload's Local API defaults to overrideAccess: true,
|
|
20
|
+
* which BYPASSES collection access control. Any request-context call must set
|
|
21
|
+
* overrideAccess: false (and pass `user`) or it silently runs as admin.
|
|
22
|
+
*/
|
|
23
|
+
const localApiOverride = {
|
|
24
|
+
id: 'local-api-override-access',
|
|
25
|
+
category: 'security',
|
|
26
|
+
describe: 'Local API call without overrideAccess:false bypasses access control',
|
|
27
|
+
run(file, ctx) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
if ((0, util_1.isSystemPath)(file.getFilePath()))
|
|
30
|
+
return findings;
|
|
31
|
+
const requestCtx = (0, util_1.looksLikeRequestFile)(file);
|
|
32
|
+
for (const call of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
33
|
+
const local = (0, util_1.isLocalApiCall)(call);
|
|
34
|
+
if (!local)
|
|
35
|
+
continue;
|
|
36
|
+
// Can't see into a spread; assume it may carry overrideAccess:false.
|
|
37
|
+
if ((0, util_1.hasSpread)(local.arg))
|
|
38
|
+
continue;
|
|
39
|
+
const overrideText = (0, util_1.propText)(local.arg, 'overrideAccess');
|
|
40
|
+
if (overrideText === 'false')
|
|
41
|
+
continue; // explicitly safe
|
|
42
|
+
const isMutation = util_1.MUTATION_METHODS.has(local.method);
|
|
43
|
+
const hasUser = (0, util_1.hasProp)(local.arg, 'user');
|
|
44
|
+
if (overrideText === 'true') {
|
|
45
|
+
// overrideAccess:true + user is contradictory -> reported by override-access-true-with-user
|
|
46
|
+
if (hasUser)
|
|
47
|
+
continue;
|
|
48
|
+
// explicit system write with no user -> intentional; surface as info only
|
|
49
|
+
findings.push((0, util_1.makeFinding)(call, ctx, 'local-api-override-access', 'security', 'info', `payload.${local.method}() uses explicit overrideAccess:true with no user (system write)`, 'Fine for cron/migrations/webhooks. If this ever runs for a user, pass overrideAccess:false and user.'));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// overrideAccess not set -> defaults to true (the real footgun)
|
|
53
|
+
// In server/job context (cron, webhooks, sync, migrations) this is expected -> info.
|
|
54
|
+
// Otherwise mutations are serious; reads only flagged in request files.
|
|
55
|
+
const serverJob = (0, util_1.isServerJobPath)(file.getFilePath());
|
|
56
|
+
if (!isMutation && !requestCtx && !serverJob)
|
|
57
|
+
continue;
|
|
58
|
+
const severity = serverJob ? 'info' : isMutation ? 'error' : 'warning';
|
|
59
|
+
findings.push((0, util_1.makeFinding)(call, ctx, 'local-api-override-access', 'security', severity, `payload.${local.method}() runs with overrideAccess:true by default, bypassing collection access control`, serverJob
|
|
60
|
+
? 'System/job context — overrideAccess defaulting to true is expected here. Only pass overrideAccess:false + user if this can ever run on behalf of an end user.'
|
|
61
|
+
: 'Pass overrideAccess:false and user, or verify ownership manually (defense in depth).'));
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
/** overrideAccess:true while a user is also passed is contradictory and dangerous. */
|
|
67
|
+
const overrideTrueWithUser = {
|
|
68
|
+
id: 'override-access-true-with-user',
|
|
69
|
+
category: 'security',
|
|
70
|
+
describe: 'overrideAccess:true alongside a user defeats access control on purpose',
|
|
71
|
+
run(file, ctx) {
|
|
72
|
+
const findings = [];
|
|
73
|
+
if ((0, util_1.isSystemPath)(file.getFilePath()))
|
|
74
|
+
return findings;
|
|
75
|
+
for (const call of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)) {
|
|
76
|
+
const local = (0, util_1.isLocalApiCall)(call);
|
|
77
|
+
if (!local)
|
|
78
|
+
continue;
|
|
79
|
+
if ((0, util_1.propText)(local.arg, 'overrideAccess') !== 'true')
|
|
80
|
+
continue;
|
|
81
|
+
if (!(0, util_1.hasProp)(local.arg, 'user'))
|
|
82
|
+
continue;
|
|
83
|
+
findings.push((0, util_1.makeFinding)(call, ctx, 'override-access-true-with-user', 'security', 'error', `payload.${local.method}() sets overrideAccess:true while passing a user — access control is skipped`, 'Use overrideAccess:false in request context; reserve overrideAccess:true for system writes with no user.'));
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
/** A collection with no explicit access control relies on framework defaults. */
|
|
89
|
+
const collectionMissingAccess = {
|
|
90
|
+
id: 'collection-missing-access',
|
|
91
|
+
category: 'security',
|
|
92
|
+
describe: 'Collection has no explicit access control block',
|
|
93
|
+
run(file, ctx) {
|
|
94
|
+
const findings = [];
|
|
95
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
96
|
+
if (!(0, util_1.isCollectionConfig)(obj))
|
|
97
|
+
continue;
|
|
98
|
+
if ((0, util_1.hasProp)(obj, 'access'))
|
|
99
|
+
continue;
|
|
100
|
+
const slug = (0, util_1.unquote)((0, util_1.propText)(obj, 'slug')) ?? 'unknown';
|
|
101
|
+
findings.push((0, util_1.makeFinding)(obj, ctx, 'collection-missing-access', 'security', 'warning', `"${slug}" has no explicit access control — operations fall back to framework defaults`, 'Define access.read / create / update / delete explicitly (owner-or-admin where-constraint for user data).', `// collection "${slug}": define access explicitly\naccess: {\n read: ownerOrAdmin,\n create: authenticated,\n update: ownerOrAdmin,\n delete: ({ req }) => isAdmin(req.user),\n}`));
|
|
102
|
+
}
|
|
103
|
+
return findings;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
/** read/create/update/delete access function that returns true grants the world. */
|
|
107
|
+
const openAccessFunction = {
|
|
108
|
+
id: 'open-access-function',
|
|
109
|
+
category: 'security',
|
|
110
|
+
describe: 'Access function returns true (open to everyone)',
|
|
111
|
+
run(file, ctx) {
|
|
112
|
+
const findings = [];
|
|
113
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
114
|
+
if (!(0, util_1.isCollectionConfig)(obj))
|
|
115
|
+
continue;
|
|
116
|
+
const accessInit = (0, util_1.propInit)(obj, 'access');
|
|
117
|
+
if (!accessInit || !ts_morph_1.Node.isObjectLiteralExpression(accessInit))
|
|
118
|
+
continue;
|
|
119
|
+
const slug = (0, util_1.unquote)((0, util_1.propText)(obj, 'slug')) ?? 'unknown';
|
|
120
|
+
const auth = (0, util_1.isAuthCollection)(obj);
|
|
121
|
+
for (const op of ['create', 'update', 'delete', 'read']) {
|
|
122
|
+
const init = (0, util_1.propInit)(accessInit, op);
|
|
123
|
+
if (!(0, util_1.returnsTrue)(init))
|
|
124
|
+
continue;
|
|
125
|
+
const isWrite = op !== 'read';
|
|
126
|
+
// public registration (create on an auth collection) is a normal pattern
|
|
127
|
+
const publicRegistration = op === 'create' && auth;
|
|
128
|
+
const severity = !isWrite || publicRegistration ? 'info' : 'error';
|
|
129
|
+
const message = publicRegistration
|
|
130
|
+
? `Auth collection "${slug}" allows public create (registration)`
|
|
131
|
+
: isWrite
|
|
132
|
+
? `Collection "${slug}" has access.${op} returning true — anyone can write`
|
|
133
|
+
: `Collection "${slug}" has access.${op} returning true — fully public read`;
|
|
134
|
+
const hint = publicRegistration
|
|
135
|
+
? 'Confirm public registration is intended; otherwise restrict create.'
|
|
136
|
+
: isWrite
|
|
137
|
+
? 'Restrict to owner-or-admin (return a where-constraint for non-admins).'
|
|
138
|
+
: 'Confirm this collection is intended to be public.';
|
|
139
|
+
findings.push((0, util_1.makeFinding)(init, ctx, 'open-access-function', 'security', severity, message, hint));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return findings;
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Classify a collection's `access.create`. "owner" patterns (ownerOrAdmin) count
|
|
147
|
+
* as user-facing, not admin-restricted — only admin/role-gated or `false` create
|
|
148
|
+
* is "restricted" (the system controls creation).
|
|
149
|
+
*/
|
|
150
|
+
function classifyCreateAccess(obj) {
|
|
151
|
+
const access = (0, util_1.propInit)(obj, 'access');
|
|
152
|
+
if (!access || !ts_morph_1.Node.isObjectLiteralExpression(access))
|
|
153
|
+
return 'unknown';
|
|
154
|
+
const create = (0, util_1.propInit)(access, 'create');
|
|
155
|
+
if (!create)
|
|
156
|
+
return 'unknown';
|
|
157
|
+
if ((0, util_1.returnsTrue)(create))
|
|
158
|
+
return 'public';
|
|
159
|
+
const text = create.getText().replace(/\s+/g, ' ');
|
|
160
|
+
const lc = text.toLowerCase();
|
|
161
|
+
const hasOwner = /owner|isowner|ownuser/.test(lc);
|
|
162
|
+
if (!hasOwner && /=>\s*false|return\s+false/.test(text))
|
|
163
|
+
return 'restricted';
|
|
164
|
+
if (!hasOwner && /(admin|\brole|staff|superuser|permission)/.test(lc))
|
|
165
|
+
return 'restricted';
|
|
166
|
+
if (hasOwner)
|
|
167
|
+
return 'authenticated';
|
|
168
|
+
if (/authenticated/.test(lc) || /req\.user|\buser\b/.test(lc))
|
|
169
|
+
return 'authenticated';
|
|
170
|
+
return 'unknown';
|
|
171
|
+
}
|
|
172
|
+
/** Does the collection have a beforeChange/beforeValidate hook (may sanitize input)? */
|
|
173
|
+
function hasMutatingHook(obj) {
|
|
174
|
+
const hooksInit = (0, util_1.propInit)(obj, 'hooks');
|
|
175
|
+
return (!!hooksInit &&
|
|
176
|
+
ts_morph_1.Node.isObjectLiteralExpression(hooksInit) &&
|
|
177
|
+
/beforeValidate|beforeChange/.test(hooksInit.getText()));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* If a collection links to users and allows create, a client could supply an
|
|
181
|
+
* arbitrary owner. Ownership must be forced in a beforeValidate hook. If create
|
|
182
|
+
* is admin/system-restricted, the system controls the owner, so this is info.
|
|
183
|
+
*/
|
|
184
|
+
const missingOwnerEnforcement = {
|
|
185
|
+
id: 'missing-owner-enforcement',
|
|
186
|
+
category: 'security',
|
|
187
|
+
describe: 'User-owned collection does not force ownership on create',
|
|
188
|
+
run(file, ctx) {
|
|
189
|
+
const findings = [];
|
|
190
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
191
|
+
if (!(0, util_1.isCollectionConfig)(obj))
|
|
192
|
+
continue;
|
|
193
|
+
const slug = (0, util_1.unquote)((0, util_1.propText)(obj, 'slug')) ?? 'unknown';
|
|
194
|
+
if (slug === 'users')
|
|
195
|
+
continue; // identity is `id`, handled elsewhere
|
|
196
|
+
const fields = (0, util_1.getFieldObjects)(obj);
|
|
197
|
+
const ownerField = fields.find((f) => {
|
|
198
|
+
const rel = (0, util_1.unquote)((0, util_1.propText)(f, 'relationTo'));
|
|
199
|
+
const name = (0, util_1.unquote)((0, util_1.propText)(f, 'name'));
|
|
200
|
+
return rel === 'users' || (name ? /^(user|owner|author|createdby)$/i.test(name) : false);
|
|
201
|
+
});
|
|
202
|
+
if (!ownerField)
|
|
203
|
+
continue;
|
|
204
|
+
// Suppress if ownership could plausibly be enforced:
|
|
205
|
+
// - a beforeValidate/beforeChange hook exists (may live in an imported fn), or
|
|
206
|
+
// - the owner field has a defaultValue (often req.user.id).
|
|
207
|
+
let enforced = hasMutatingHook(obj);
|
|
208
|
+
if ((0, util_1.hasProp)(ownerField, 'defaultValue'))
|
|
209
|
+
enforced = true;
|
|
210
|
+
if (enforced)
|
|
211
|
+
continue;
|
|
212
|
+
// If creation is admin/system-restricted, the system stamps the owner -> info, not warning.
|
|
213
|
+
const restricted = classifyCreateAccess(obj) === 'restricted';
|
|
214
|
+
const ownerName = (0, util_1.unquote)((0, util_1.propText)(ownerField, 'name')) ?? 'user';
|
|
215
|
+
findings.push((0, util_1.makeFinding)(ownerField, ctx, 'missing-owner-enforcement', 'security', restricted ? 'info' : 'warning', restricted
|
|
216
|
+
? `Collection "${slug}" links to a user; creation is admin/system-restricted, so the owner is system-set (verify it really is)`
|
|
217
|
+
: `Collection "${slug}" links to a user but does not force ownership on create`, 'Add a beforeValidate hook that sets data.user = req.user.id on create; never trust a client-supplied owner.', `// collection "${slug}": stamp the owner on create, ignore client input\nhooks: { beforeChange: [({ req, data, operation }) => {\n if (operation === 'create' && req.user) data.${ownerName} = req.user.id\n return data\n}] }`));
|
|
218
|
+
}
|
|
219
|
+
return findings;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
/** A privileged field (roles, isAdmin, ...) without field-level update access escalates. */
|
|
223
|
+
const userWritablePrivilegedField = {
|
|
224
|
+
id: 'user-writable-privileged-field',
|
|
225
|
+
category: 'security',
|
|
226
|
+
describe: 'Privileged field lacks field-level update access (privilege escalation)',
|
|
227
|
+
run(file, ctx) {
|
|
228
|
+
const findings = [];
|
|
229
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
230
|
+
if (!(0, util_1.isCollectionConfig)(obj))
|
|
231
|
+
continue;
|
|
232
|
+
const slug = (0, util_1.unquote)((0, util_1.propText)(obj, 'slug')) ?? 'unknown';
|
|
233
|
+
for (const f of (0, util_1.getFieldObjects)(obj)) {
|
|
234
|
+
const name = (0, util_1.unquote)((0, util_1.propText)(f, 'name'));
|
|
235
|
+
if (!name || !(0, util_1.hasSegment)(name, PRIVILEGED_WORDS))
|
|
236
|
+
continue;
|
|
237
|
+
const accessInit = (0, util_1.propInit)(f, 'access');
|
|
238
|
+
const hasUpdateGate = accessInit &&
|
|
239
|
+
ts_morph_1.Node.isObjectLiteralExpression(accessInit) &&
|
|
240
|
+
(0, util_1.hasProp)(accessInit, 'update');
|
|
241
|
+
if (hasUpdateGate)
|
|
242
|
+
continue;
|
|
243
|
+
findings.push((0, util_1.makeFinding)(f, ctx, 'user-writable-privileged-field', 'security', 'error', `Field "${name}" on "${slug}" has no field-level access.update — a user could escalate their own privileges`, 'Add field access: { update: ({ req }) => isAdmin(req.user) } to lock the field to admins.'));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return findings;
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
/**
|
|
250
|
+
* Mass assignment / privilege escalation on create: an AUTH collection whose
|
|
251
|
+
* `create` access is open (public or any authenticated user) has a privileged
|
|
252
|
+
* field (roles/isAdmin/…) with no field-level `access.create` and no sanitizing
|
|
253
|
+
* hook — so a registrant could set `roles: ['admin']` in the create payload.
|
|
254
|
+
* Scoped to auth collections because a `roles` field on a content collection is
|
|
255
|
+
* not a privilege vector. Complements user-writable-privileged-field (update path).
|
|
256
|
+
*/
|
|
257
|
+
const massAssignment = {
|
|
258
|
+
id: 'mass-assignment',
|
|
259
|
+
category: 'security',
|
|
260
|
+
describe: 'Privileged field settable on create (open create, no field access.create)',
|
|
261
|
+
run(file, ctx) {
|
|
262
|
+
const findings = [];
|
|
263
|
+
for (const obj of file.getDescendantsOfKind(ts_morph_1.SyntaxKind.ObjectLiteralExpression)) {
|
|
264
|
+
if (!(0, util_1.isCollectionConfig)(obj))
|
|
265
|
+
continue;
|
|
266
|
+
const slug = (0, util_1.unquote)((0, util_1.propText)(obj, 'slug')) ?? 'unknown';
|
|
267
|
+
if (!(0, util_1.isAuthCollection)(obj) && slug !== 'users')
|
|
268
|
+
continue;
|
|
269
|
+
const createClass = classifyCreateAccess(obj);
|
|
270
|
+
if (createClass !== 'public' && createClass !== 'authenticated')
|
|
271
|
+
continue;
|
|
272
|
+
if (hasMutatingHook(obj))
|
|
273
|
+
continue; // a beforeChange hook may strip the field on create
|
|
274
|
+
for (const f of (0, util_1.getFieldObjects)(obj)) {
|
|
275
|
+
const name = (0, util_1.unquote)((0, util_1.propText)(f, 'name'));
|
|
276
|
+
if (!name || !(0, util_1.hasSegment)(name, PRIVILEGED_WORDS))
|
|
277
|
+
continue;
|
|
278
|
+
const accessInit = (0, util_1.propInit)(f, 'access');
|
|
279
|
+
let createGated = false;
|
|
280
|
+
if (accessInit && ts_morph_1.Node.isObjectLiteralExpression(accessInit)) {
|
|
281
|
+
const fCreate = (0, util_1.propInit)(accessInit, 'create');
|
|
282
|
+
if (fCreate && !(0, util_1.returnsTrue)(fCreate))
|
|
283
|
+
createGated = true;
|
|
284
|
+
}
|
|
285
|
+
if (createGated)
|
|
286
|
+
continue;
|
|
287
|
+
const severity = createClass === 'public' ? 'error' : 'warning';
|
|
288
|
+
const who = createClass === 'public' ? 'Anyone (create is public)' : 'Any authenticated user';
|
|
289
|
+
findings.push((0, util_1.makeFinding)(f, ctx, 'mass-assignment', 'security', severity, `Privileged field "${name}" on "${slug}" can be set on create — ${who}, and it has no field-level access.create (mass-assignment privilege escalation)`, 'Add field access: { create: ({ req }) => isAdmin(req.user) } so only admins can set it, even on create. The defaultValue still applies for normal sign-ups.', `// field "${name}" on "${slug}": add a field-level create gate\naccess: { create: ({ req }) => req.user?.roles?.includes('admin') ?? false }`));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return findings;
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
exports.accessChecks = [
|
|
296
|
+
localApiOverride,
|
|
297
|
+
overrideTrueWithUser,
|
|
298
|
+
collectionMissingAccess,
|
|
299
|
+
openAccessFunction,
|
|
300
|
+
missingOwnerEnforcement,
|
|
301
|
+
userWritablePrivilegedField,
|
|
302
|
+
massAssignment,
|
|
303
|
+
];
|