diff --git a/packages/apps-vtex/src/invoke.ts b/packages/apps-vtex/src/invoke.ts index 9163411..daeb7f6 100644 --- a/packages/apps-vtex/src/invoke.ts +++ b/packages/apps-vtex/src/invoke.ts @@ -37,15 +37,6 @@ import { updateCartItems, updateOrderFormAttachment, } from "./actions/checkout"; -import { - type CreateDocumentResult, - createDocument, - getDocument, - patchDocument, - searchDocuments, - type UploadAttachmentOpts, - uploadAttachment, -} from "./actions/masterData"; import { type NotifyMeProps, notifyMe } from "./actions/misc"; import { type SubscribeProps, subscribe } from "./actions/newsletter"; import { createSession, editSession, type SessionData } from "./actions/session"; @@ -139,31 +130,30 @@ export const invoke = { }) => Promise, // -- MasterData ------------------------------------------------------- - - createDocument: createInvokeFn((data: { entity: string; data: Record }) => - createDocument(data), - ) as unknown as (ctx: { - data: { entity: string; data: Record }; - }) => Promise, - - getDocument: createInvokeFn((data: { entity: string; documentId: string }) => - getDocument(data), - ), - - patchDocument: createInvokeFn( - (data: { entity: string; documentId: string; data: Record }) => - patchDocument(data), - ) as unknown as (ctx: { - data: { entity: string; documentId: string; data: Record }; - }) => Promise, - - searchDocuments: createInvokeFn((data: { entity: string; filter: string }) => - searchDocuments(data), - ), - - uploadAttachment: createInvokeFn((data: UploadAttachmentOpts) => - uploadAttachment(data), - ) as unknown as (ctx: { data: UploadAttachmentOpts }) => Promise<{ ok: true }>, + // + // Generic MasterData CRUD (createDocument / getDocument / patchDocument / + // searchDocuments / uploadAttachment) is deliberately NOT exposed here. + // + // Every entry in this object is emitted by @decocms/blocks-cli's + // generate-invoke.ts as a top-level createServerFn, which TanStack Start + // compiles into a public, unauthenticated /_serverFn/ endpoint. A + // generic action that takes a client-supplied `entity` and proxies to the + // VTEX MasterData API with the app's admin appKey/appToken lets any caller + // read/enumerate/write arbitrary entities — e.g. searchDocuments({ entity: + // "CL", ... }) dumps the Clients (customer PII) table. Authentication alone + // can't make this safe: the generic (entity, filter) shape has no room for + // per-entity/per-record authorization. + // + // The underlying functions remain exported from ./actions/masterData for + // SERVER-SIDE use. A site that needs MasterData defines a narrow, + // entity-pinned, validated action in its own src/server/invoke.ts: pin + // `entity` server-side (never take it from the client), validate input, and + // for user-scoped data derive identity from the session cookie + // (parseVtexAuthJwt) rather than a client-supplied id. See `subscribe` / + // `notifyMe` below for the purpose-specific pattern. + // + // generate-invoke.ts also carries a PRIVILEGED_ACTIONS guard that refuses + // to emit these names even if re-added here, so this stays enforced. // -- Newsletter ------------------------------------------------------- diff --git a/packages/blocks-cli/scripts/generate-invoke.test.ts b/packages/blocks-cli/scripts/generate-invoke.test.ts index 17220e0..57bac86 100644 --- a/packages/blocks-cli/scripts/generate-invoke.test.ts +++ b/packages/blocks-cli/scripts/generate-invoke.test.ts @@ -268,3 +268,111 @@ describe("generate-invoke.ts — default --apps-dir resolution", () => { } }, 30_000); }); + +describe("generate-invoke.ts — privileged MasterData actions are never exposed", () => { + // Security regression guard: generic admin-credentialed MasterData CRUD + // (searchDocuments / createDocument / patchDocument / getDocument / + // uploadAttachment) must NOT be emitted as createServerFn endpoints, even if + // they appear in invoke.vtex.actions. Each emitted action is a public, + // unauthenticated /_serverFn/; a generic (entity, filter) proxy over the + // app's appKey/appToken is broken access control (e.g. entity: "CL" dumps + // customer PII). The generator's PRIVILEGED_ACTIONS guard must skip them. + const FIXTURE_WITH_PRIVILEGED = `\ +import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke"; +import { getOrCreateCart } from "./actions/checkout"; +import { createDocument, searchDocuments, patchDocument } from "./actions/masterData"; +import type { OrderForm } from "./types"; + +export const invoke = { + vtex: { + actions: { + getOrCreateCart: createInvokeFn( + (data: { orderFormId?: string }) => getOrCreateCart(data), + ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, + + createDocument: createInvokeFn( + (data: { entity: string; data: Record }) => createDocument(data), + ), + searchDocuments: createInvokeFn( + (data: { entity: string; filter: string }) => searchDocuments(data), + ), + patchDocument: createInvokeFn( + (data: { entity: string; documentId: string; data: Record }) => + patchDocument(data), + ), + }, + }, +} as const; +`; + const FIXTURE_ACTIONS_MASTERDATA_TS = `\ +export async function createDocument(_p: any): Promise { return null; } +export async function searchDocuments(_p: any): Promise { return null; } +export async function patchDocument(_p: any): Promise { return null; } +`; + + let generatedOutput: string; + let stderr: string; + let status: number | null; + let cleanup: () => void; + + beforeAll(() => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-priv-")); + const appsDir = path.join(tmp, "apps-vtex"); + const siteDir = path.join(tmp, "site"); + fs.mkdirSync(path.join(appsDir, "actions"), { recursive: true }); + fs.mkdirSync(path.join(siteDir, "src", "server"), { recursive: true }); + fs.writeFileSync(path.join(appsDir, "invoke.ts"), FIXTURE_WITH_PRIVILEGED); + fs.writeFileSync(path.join(appsDir, "actions", "checkout.ts"), FIXTURE_ACTIONS_CHECKOUT_TS); + fs.writeFileSync(path.join(appsDir, "actions", "masterData.ts"), FIXTURE_ACTIONS_MASTERDATA_TS); + fs.writeFileSync(path.join(appsDir, "types.ts"), FIXTURE_TYPES_TS); + const outFile = path.join(siteDir, "src", "server", "invoke.gen.ts"); + const result = spawnSync( + "npx", + ["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile], + { cwd: siteDir, encoding: "utf8" }, + ); + status = result.status; + stderr = result.stderr; + generatedOutput = fs.readFileSync(outFile, "utf8"); + cleanup = () => fs.rmSync(tmp, { recursive: true, force: true }); + }, 30_000); + + afterAll(() => { + try { + cleanup(); + } catch { + // ignore + } + }); + + it("runs to completion", () => { + expect(status, stderr).toBe(0); + }); + + it("does NOT emit createServerFn consts for privileged MasterData actions", () => { + expect(generatedOutput).not.toContain("$createDocument"); + expect(generatedOutput).not.toContain("$searchDocuments"); + expect(generatedOutput).not.toContain("$patchDocument"); + }); + + it("does NOT list privileged actions in the exported vtexActions map", () => { + expect(generatedOutput).not.toMatch(/^\s*createDocument:/m); + expect(generatedOutput).not.toMatch(/^\s*searchDocuments:/m); + expect(generatedOutput).not.toMatch(/^\s*patchDocument:/m); + }); + + it("does NOT import the privileged action functions", () => { + expect(generatedOutput).not.toContain("actions/masterData"); + }); + + it("still emits the safe action alongside the skipped ones", () => { + expect(generatedOutput).toContain("const $getOrCreateCart = createServerFn"); + expect(generatedOutput).toContain("const result = await getOrCreateCart(data);"); + }); + + it("warns on stderr for each skipped privileged action", () => { + expect(stderr).toContain('Skipping privileged action "createDocument"'); + expect(stderr).toContain('Skipping privileged action "searchDocuments"'); + expect(stderr).toContain('Skipping privileged action "patchDocument"'); + }); +}); diff --git a/packages/blocks-cli/scripts/generate-invoke.ts b/packages/blocks-cli/scripts/generate-invoke.ts index c4807e6..d216e55 100644 --- a/packages/blocks-cli/scripts/generate-invoke.ts +++ b/packages/blocks-cli/scripts/generate-invoke.ts @@ -51,6 +51,25 @@ function arg(name: string, fallback: string): string { const cwd = process.cwd(); const outFile = path.resolve(cwd, arg("out-file", "src/server/invoke.gen.ts")); +// Security guard: generic, admin-credentialed VTEX MasterData CRUD must never +// be emitted as a client-callable /_serverFn endpoint. Each emitted action +// becomes a public, unauthenticated createServerFn; a generic action that takes +// a client-supplied `entity` and hits MasterData with the app's appKey/appToken +// is broken access control (e.g. searchDocuments({ entity: "CL" }) dumps +// customer PII). These names are refused even if present in invoke.vtex.actions, +// so the exclusion can't silently regress via an edit to the source contract. +// Sites needing MasterData define a narrow, entity-pinned, authenticated action +// in their own src/server/invoke.ts instead (the masterData.ts functions stay +// exported for that server-side use). +const PRIVILEGED_ACTIONS = new Set([ + "createDocument", + "getDocument", + "patchDocument", + "searchDocuments", + "searchDocumentsFull", + "uploadAttachment", +]); + function resolveAppsDir(): string { const explicit = arg("apps-dir", ""); if (explicit) return path.resolve(cwd, explicit); @@ -195,6 +214,17 @@ for (const prop of actionsObj.getProperties()) { if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue; const pa = prop as PropertyAssignment; const name = pa.getName(); + + // Never emit generic admin-credentialed MasterData CRUD as a serverFn — see + // PRIVILEGED_ACTIONS above. Skipping here means no top-level createServerFn + // const and no vtexActions entry is generated for it. + if (PRIVILEGED_ACTIONS.has(name)) { + console.warn( + `⚠ Skipping privileged action "${name}" — generic MasterData CRUD is not exposed as a serverFn (security).`, + ); + continue; + } + const initText = pa.getInitializer()!.getText(); // Check if it uses createInvokeFn with unwrap