Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 24 additions & 34 deletions packages/apps-vtex/src/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -139,31 +130,30 @@ export const invoke = {
}) => Promise<SessionData>,

// -- MasterData -------------------------------------------------------

createDocument: createInvokeFn((data: { entity: string; data: Record<string, any> }) =>
createDocument(data),
) as unknown as (ctx: {
data: { entity: string; data: Record<string, any> };
}) => Promise<CreateDocumentResult>,

getDocument: createInvokeFn((data: { entity: string; documentId: string }) =>
getDocument(data),
),

patchDocument: createInvokeFn(
(data: { entity: string; documentId: string; data: Record<string, any> }) =>
patchDocument(data),
) as unknown as (ctx: {
data: { entity: string; documentId: string; data: Record<string, any> };
}) => Promise<void>,

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/<hash> 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 -------------------------------------------------------

Expand Down
108 changes: 108 additions & 0 deletions packages/blocks-cli/scripts/generate-invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<hash>; 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<OrderForm>,

createDocument: createInvokeFn(
(data: { entity: string; data: Record<string, any> }) => createDocument(data),
),
searchDocuments: createInvokeFn(
(data: { entity: string; filter: string }) => searchDocuments(data),
),
patchDocument: createInvokeFn(
(data: { entity: string; documentId: string; data: Record<string, any> }) =>
patchDocument(data),
),
},
},
} as const;
`;
const FIXTURE_ACTIONS_MASTERDATA_TS = `\
export async function createDocument(_p: any): Promise<any> { return null; }
export async function searchDocuments(_p: any): Promise<any> { return null; }
export async function patchDocument(_p: any): Promise<any> { 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"');
});
});
30 changes: 30 additions & 0 deletions packages/blocks-cli/scripts/generate-invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down