@decocms/blocks-cli 7.20.1 → 7.20.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks-cli",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
|
|
6
6
|
"repository": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"lint:unused": "knip"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@decocms/blocks": "7.20.
|
|
33
|
+
"@decocms/blocks": "7.20.2",
|
|
34
34
|
"ts-morph": "^27.0.0",
|
|
35
35
|
"tsx": "^4.22.5"
|
|
36
36
|
},
|
|
@@ -268,3 +268,111 @@ describe("generate-invoke.ts — default --apps-dir resolution", () => {
|
|
|
268
268
|
}
|
|
269
269
|
}, 30_000);
|
|
270
270
|
});
|
|
271
|
+
|
|
272
|
+
describe("generate-invoke.ts — privileged MasterData actions are never exposed", () => {
|
|
273
|
+
// Security regression guard: generic admin-credentialed MasterData CRUD
|
|
274
|
+
// (searchDocuments / createDocument / patchDocument / getDocument /
|
|
275
|
+
// uploadAttachment) must NOT be emitted as createServerFn endpoints, even if
|
|
276
|
+
// they appear in invoke.vtex.actions. Each emitted action is a public,
|
|
277
|
+
// unauthenticated /_serverFn/<hash>; a generic (entity, filter) proxy over the
|
|
278
|
+
// app's appKey/appToken is broken access control (e.g. entity: "CL" dumps
|
|
279
|
+
// customer PII). The generator's PRIVILEGED_ACTIONS guard must skip them.
|
|
280
|
+
const FIXTURE_WITH_PRIVILEGED = `\
|
|
281
|
+
import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke";
|
|
282
|
+
import { getOrCreateCart } from "./actions/checkout";
|
|
283
|
+
import { createDocument, searchDocuments, patchDocument } from "./actions/masterData";
|
|
284
|
+
import type { OrderForm } from "./types";
|
|
285
|
+
|
|
286
|
+
export const invoke = {
|
|
287
|
+
vtex: {
|
|
288
|
+
actions: {
|
|
289
|
+
getOrCreateCart: createInvokeFn(
|
|
290
|
+
(data: { orderFormId?: string }) => getOrCreateCart(data),
|
|
291
|
+
) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
|
|
292
|
+
|
|
293
|
+
createDocument: createInvokeFn(
|
|
294
|
+
(data: { entity: string; data: Record<string, any> }) => createDocument(data),
|
|
295
|
+
),
|
|
296
|
+
searchDocuments: createInvokeFn(
|
|
297
|
+
(data: { entity: string; filter: string }) => searchDocuments(data),
|
|
298
|
+
),
|
|
299
|
+
patchDocument: createInvokeFn(
|
|
300
|
+
(data: { entity: string; documentId: string; data: Record<string, any> }) =>
|
|
301
|
+
patchDocument(data),
|
|
302
|
+
),
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
} as const;
|
|
306
|
+
`;
|
|
307
|
+
const FIXTURE_ACTIONS_MASTERDATA_TS = `\
|
|
308
|
+
export async function createDocument(_p: any): Promise<any> { return null; }
|
|
309
|
+
export async function searchDocuments(_p: any): Promise<any> { return null; }
|
|
310
|
+
export async function patchDocument(_p: any): Promise<any> { return null; }
|
|
311
|
+
`;
|
|
312
|
+
|
|
313
|
+
let generatedOutput: string;
|
|
314
|
+
let stderr: string;
|
|
315
|
+
let status: number | null;
|
|
316
|
+
let cleanup: () => void;
|
|
317
|
+
|
|
318
|
+
beforeAll(() => {
|
|
319
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-priv-"));
|
|
320
|
+
const appsDir = path.join(tmp, "apps-vtex");
|
|
321
|
+
const siteDir = path.join(tmp, "site");
|
|
322
|
+
fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true });
|
|
323
|
+
fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true });
|
|
324
|
+
fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_WITH_PRIVILEGED);
|
|
325
|
+
fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS);
|
|
326
|
+
fs.writeFileSync(path.join(appsDir, "actions", "masterData.ts"), FIXTURE_ACTIONS_MASTERDATA_TS);
|
|
327
|
+
fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS);
|
|
328
|
+
const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
329
|
+
const result = spawnSync(
|
|
330
|
+
"npx",
|
|
331
|
+
["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
|
|
332
|
+
{ cwd: siteDir, encoding: "utf8" },
|
|
333
|
+
);
|
|
334
|
+
status = result.status;
|
|
335
|
+
stderr = result.stderr;
|
|
336
|
+
generatedOutput = fs.readFileSync(outFile, "utf8");
|
|
337
|
+
cleanup = () => fs.rmSync(tmp, { recursive: true, force: true });
|
|
338
|
+
}, 30_000);
|
|
339
|
+
|
|
340
|
+
afterAll(() => {
|
|
341
|
+
try {
|
|
342
|
+
cleanup();
|
|
343
|
+
} catch {
|
|
344
|
+
// ignore
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("runs to completion", () => {
|
|
349
|
+
expect(status, stderr).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("does NOT emit createServerFn consts for privileged MasterData actions", () => {
|
|
353
|
+
expect(generatedOutput).not.toContain("$createDocument");
|
|
354
|
+
expect(generatedOutput).not.toContain("$searchDocuments");
|
|
355
|
+
expect(generatedOutput).not.toContain("$patchDocument");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("does NOT list privileged actions in the exported vtexActions map", () => {
|
|
359
|
+
expect(generatedOutput).not.toMatch(/^\s*createDocument:/m);
|
|
360
|
+
expect(generatedOutput).not.toMatch(/^\s*searchDocuments:/m);
|
|
361
|
+
expect(generatedOutput).not.toMatch(/^\s*patchDocument:/m);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("does NOT import the privileged action functions", () => {
|
|
365
|
+
expect(generatedOutput).not.toContain("actions/masterData");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("still emits the safe action alongside the skipped ones", () => {
|
|
369
|
+
expect(generatedOutput).toContain("const $getOrCreateCart = createServerFn");
|
|
370
|
+
expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("warns on stderr for each skipped privileged action", () => {
|
|
374
|
+
expect(stderr).toContain('Skipping privileged action "createDocument"');
|
|
375
|
+
expect(stderr).toContain('Skipping privileged action "searchDocuments"');
|
|
376
|
+
expect(stderr).toContain('Skipping privileged action "patchDocument"');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -56,6 +56,25 @@ function arg(name: string, fallback: string): string {
|
|
|
56
56
|
const cwd = process.cwd();
|
|
57
57
|
const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts"));
|
|
58
58
|
|
|
59
|
+
// Security guard: generic, admin-credentialed VTEX MasterData CRUD must never
|
|
60
|
+
// be emitted as a client-callable /_serverFn endpoint. Each emitted action
|
|
61
|
+
// becomes a public, unauthenticated createServerFn; a generic action that takes
|
|
62
|
+
// a client-supplied `entity` and hits MasterData with the app's appKey/appToken
|
|
63
|
+
// is broken access control (e.g. searchDocuments({ entity: "CL" }) dumps
|
|
64
|
+
// customer PII). These names are refused even if present in invoke.vtex.actions,
|
|
65
|
+
// so the exclusion can't silently regress via an edit to the source contract.
|
|
66
|
+
// Sites needing MasterData define a narrow, entity-pinned, authenticated action
|
|
67
|
+
// in their own src/server/invoke.ts instead (the masterData.ts functions stay
|
|
68
|
+
// exported for that server-side use).
|
|
69
|
+
const PRIVILEGED_ACTIONS = new Set([
|
|
70
|
+
"createDocument",
|
|
71
|
+
"getDocument",
|
|
72
|
+
"patchDocument",
|
|
73
|
+
"searchDocuments",
|
|
74
|
+
"searchDocumentsFull",
|
|
75
|
+
"uploadAttachment",
|
|
76
|
+
]);
|
|
77
|
+
|
|
59
78
|
function resolveAppsDir(): string {
|
|
60
79
|
const explicit = arg("apps-dir", "");
|
|
61
80
|
if (explicit) return path.resolve(cwd, explicit);
|
|
@@ -200,6 +219,17 @@ for (const prop of actionsObj.getProperties()) {
|
|
|
200
219
|
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
201
220
|
const pa = prop as PropertyAssignment;
|
|
202
221
|
const name = pa.getName();
|
|
222
|
+
|
|
223
|
+
// Never emit generic admin-credentialed MasterData CRUD as a serverFn — see
|
|
224
|
+
// PRIVILEGED_ACTIONS above. Skipping here means no top-level createServerFn
|
|
225
|
+
// const and no vtexActions entry is generated for it.
|
|
226
|
+
if (PRIVILEGED_ACTIONS.has(name)) {
|
|
227
|
+
console.warn(
|
|
228
|
+
`⚠ Skipping privileged action "${name}" — generic MasterData CRUD is not exposed as a serverFn (security).`,
|
|
229
|
+
);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
203
233
|
const initText = pa.getInitializer()!.getText();
|
|
204
234
|
|
|
205
235
|
// Check if it uses createInvokeFn with unwrap
|