From 1366a0f704133b6fbefb6deccaf04d845b8d8290 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Sat, 23 May 2026 21:54:57 -0300 Subject: [PATCH] Harden product webhook schema and typing --- .../contracts/schemas/product-webhook.test.ts | 66 +++++++++++++++++++ .../contracts/schemas/product-webhook.ts | 59 ++++++++++++++++- supabase/functions/product-webhook/index.ts | 6 +- 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 supabase/functions/_shared/contracts/schemas/product-webhook.test.ts diff --git a/supabase/functions/_shared/contracts/schemas/product-webhook.test.ts b/supabase/functions/_shared/contracts/schemas/product-webhook.test.ts new file mode 100644 index 000000000..052f58fcd --- /dev/null +++ b/supabase/functions/_shared/contracts/schemas/product-webhook.test.ts @@ -0,0 +1,66 @@ +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { ProductWebhookV1 } from "./product-webhook.ts"; + +Deno.test("ProductWebhookV1 rejects variation sem shape mínima", () => { + const payload = { + action: "upsert", + product: { + sku: "SKU-1", + name: "Produto", + price: 10, + variations: ["sem-objeto"], + }, + }; + + const result = ProductWebhookV1.safeParse(payload); + assertEquals(result.success, false); +}); + +Deno.test("ProductWebhookV1 rejects variation sem identificador", () => { + const payload = { + action: "upsert", + product: { + sku: "SKU-1", + name: "Produto", + price: 10, + variations: [{ color: "red" }], + }, + }; + + const result = ProductWebhookV1.safeParse(payload); + assertEquals(result.success, false); +}); + +Deno.test("ProductWebhookV1 rejects metadata com payload massivo", () => { + const metadata = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`k${i}`, i])); + + const payload = { + action: "upsert", + product: { + sku: "SKU-1", + name: "Produto", + price: 10, + metadata, + }, + }; + + const result = ProductWebhookV1.safeParse(payload); + assertEquals(result.success, false); +}); + +Deno.test("ProductWebhookV1 rejects metadata com array gigante", () => { + const payload = { + action: "upsert", + product: { + sku: "SKU-1", + name: "Produto", + price: 10, + metadata: { + huge: Array.from({ length: 101 }, (_, i) => i), + }, + }, + }; + + const result = ProductWebhookV1.safeParse(payload); + assertEquals(result.success, false); +}); diff --git a/supabase/functions/_shared/contracts/schemas/product-webhook.ts b/supabase/functions/_shared/contracts/schemas/product-webhook.ts index 78b4ae10b..8922b663e 100644 --- a/supabase/functions/_shared/contracts/schemas/product-webhook.ts +++ b/supabase/functions/_shared/contracts/schemas/product-webhook.ts @@ -13,6 +13,46 @@ import { z } from "https://esm.sh/zod@3.23.8"; +const JsonPrimitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); +type JsonValue = z.infer | { [key: string]: JsonValue } | JsonValue[]; + +const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + JsonPrimitive, + z.array(JsonValueSchema).max(100), + z.record(z.string().max(100), JsonValueSchema).superRefine((obj, ctx) => { + if (Object.keys(obj).length > 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Object must have at most 100 keys", + }); + } + }), + ]) +); + +const VariationSchema = z.unknown().superRefine((value, ctx) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation must be an object" }); + return; + } + const record = value as Record; + const keys = Object.keys(record); + if (keys.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation must not be empty" }); + } + if (keys.length > 30) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Variation has too many keys" }); + } + const candidateId = record.id ?? record.external_id ?? record.sku; + if (typeof candidateId !== "string" || candidateId.trim().length === 0 || candidateId.length > 255) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Variation must include id/external_id/sku as non-empty string up to 255 chars", + }); + } +}); + // --------------------------------------------------------------------------- // v1 (compatível com produção) // --------------------------------------------------------------------------- @@ -55,8 +95,23 @@ const ProductV1 = z.object({ ) .max(50) .optional(), - variations: z.array(z.any()).max(200).optional(), - metadata: z.record(z.any()).optional(), + variations: z.array(VariationSchema).max(200).optional(), + metadata: z.unknown().superRefine((value, ctx) => { + if (value === undefined) return; + const parsed = z.record(z.string().max(100), JsonValueSchema).safeParse(value); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue(issue); + } + return; + } + if (Object.keys(parsed.data).length > 100) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "metadata must have at most 100 keys", + }); + } + }).optional(), }); export const ProductWebhookV1 = z.object({ diff --git a/supabase/functions/product-webhook/index.ts b/supabase/functions/product-webhook/index.ts index 707a8debf..cb599c864 100644 --- a/supabase/functions/product-webhook/index.ts +++ b/supabase/functions/product-webhook/index.ts @@ -1,4 +1,5 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; +import type { SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; import { parseContract, @@ -8,6 +9,7 @@ import { type ProductWebhookV1Payload, type ProductWebhookV2Payload, } from "../_shared/contracts/schemas/product-webhook.ts"; +import type { Database } from "../../src/integrations/supabase/types.ts"; const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-webhook-secret", "accept-version"] }); @@ -25,7 +27,7 @@ Deno.serve(async (req) => { return new Response(null, { headers: corsHeaders }); } - const supabase = createClient(supabaseUrl, supabaseServiceKey); + const supabase = createClient(supabaseUrl, supabaseServiceKey); try { // Webhook secret check (mantido idêntico) @@ -148,7 +150,7 @@ Deno.serve(async (req) => { }); async function upsertProducts( - supabase: any, + supabase: SupabaseClient, products: ProductPayload[], ): Promise<{ created: number; updated: number; failed: number; errors: string[] }> { let created = 0;