Skip to content
Merged
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
1 change: 1 addition & 0 deletions supabase/functions/_shared/edge-authz-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const EDGE_AUTHZ_MANIFEST: Record<string, AuthzEntry> = {
"detect-new-device": { category: "public", rationale: "Anti-fraude pré-login" },
"bi-share-dossier": { category: "public", rationale: "Dossiê compartilhado por token" },
"dropbox-list": { category: "public", rationale: "Listagem pública de arquivos curados" },
"secure-upload": { category: "public", rationale: "Upload com scan VirusTotal anti-malware (aceita anônimo; uso real em áreas autenticadas — considerar promover a authenticated em PR futuro)" },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

secure-upload should not remain classified as public with privileged backend writes.

This codifies anonymous access for an endpoint that writes using SUPABASE_SERVICE_ROLE_KEY, which expands abuse risk (storage/cost/traffic) even with malware scanning. Please move this to authenticated (or scoped with signed token validation) in the same hardening track.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/_shared/edge-authz-manifest.ts` at line 72, The manifest
entry for "secure-upload" currently sets category: "public" which allows
anonymous use of endpoints that perform privileged writes with
SUPABASE_SERVICE_ROLE_KEY; update the "secure-upload" entry in
edge-authz-manifest.ts to use a hardened category (e.g., change category from
"public" to "authenticated" or to "scoped" and add signed token validation) so
the endpoint requires authenticated/scoped access and ensure any downstream
logic that relies on this manifest enforces the new auth check.

Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O manifest está marcando secure-upload como public, mas a função usa SUPABASE_SERVICE_ROLE_KEY para fazer upload em Storage. Isso permite que qualquer caller anônimo dispare uploads (e potencialmente abuse de custo/armazenamento) sem qualquer controle de identidade/escopo. Considerar mudar a categoria para authenticated e adicionar enforcement in-function (ex.: autenticação JWT) ou algum mecanismo equivalente (token assinado/rate limiting) antes de assumir como público no SSOT.

Suggested change
"secure-upload": { category: "public", rationale: "Upload com scan VirusTotal anti-malware (aceita anônimo; uso real em áreas autenticadas — considerar promover a authenticated em PR futuro)" },
"secure-upload": {
category: "authenticated",
rationale: "Upload aciona Storage com privilégios elevados; exige usuário autenticado para reduzir abuso de custo/armazenamento",
enforcedBy: "shared-authorize",
},

Copilot uses AI. Check for mistakes.

// ---------------- Webhooks (assinatura própria) ----------------
"webhook-inbound": { category: "scoped", rationale: "HMAC-SHA256 inline" },
Expand Down
200 changes: 124 additions & 76 deletions supabase/functions/secure-upload/index.ts
Original file line number Diff line number Diff line change
@@ -1,148 +1,196 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O import do @supabase/supabase-js está sem pin de versão (https://esm.sh/@supabase/supabase-js@2). No repo, esse import costuma estar fixado em uma versão exata (ex.: _shared/auth.ts:4 usa @2.49.4), para evitar mudanças não-determinísticas via esm.sh. Sugestão: fixar a versão aqui para alinhar com esse padrão.

Suggested change
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4";

Copilot uses AI. Check for mistakes.
import { createStructuredLogger } from "../_shared/structured-logger.ts";
import { getOrCreateRequestId } from "../_shared/request-id.ts";

const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Allow x-request-id in CORS headers for true end-to-end correlation.

Right now browser callers cannot send X-Request-Id cross-origin, so upstream correlation propagation is blocked.

Proposed patch
-  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
+  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-request-id",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/secure-upload/index.ts` at line 7, The CORS response
header list is missing "x-request-id", preventing browsers from sending
X-Request-Id cross-origin; update the Access-Control-Allow-Headers value (the
header key "Access-Control-Allow-Headers" where the string currently contains
"authorization, x-client-info, apikey, content-type") to include "x-request-id"
so browsers can forward the request ID for end-to-end correlation.

};
Comment on lines 5 to +8
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Este endpoint está com CORS wildcard (Access-Control-Allow-Origin: *). Como a função faz upload usando service-role, isso amplia a superfície de abuso (qualquer site pode chamar via browser). Se a intenção é uso em áreas autenticadas do frontend, preferir getCorsHeaders(req) do _shared/cors.ts (allowlist) em vez de wildcard.

Copilot uses AI. Check for mistakes.

interface ScanLog {
user_id: string | null;
bucket: string;
path: string;
hash: string;
scan_result: any;
scan_result: Record<string, unknown>;
status_code: number;
}

serve(async (req) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
Deno.serve(async (req) => {
const requestId = getOrCreateRequestId(req);
const log = createStructuredLogger({ fn: "secure-upload", requestId, req });

if (req.method === "OPTIONS") {
return log.respond(new Response("ok", { headers: corsHeaders }));
}

log.info("request_start");

const supabaseAdmin = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
);

// Variáveis para auditoria persistente mesmo em caso de erro
let auditData: Partial<ScanLog> = {
status_code: 500,
scan_result: { message: 'Iniciando processamento' }
scan_result: { message: "Iniciando processamento" },
};

try {
const formData = await req.formData()
const file = formData.get('file') as File
const folder = formData.get('folder') as string || 'uploads'
const authHeader = req.headers.get('Authorization')
let user = null
const formData = await req.formData();
const file = formData.get("file") as File;
const folder = (formData.get("folder") as string) || "uploads";

Comment on lines +41 to +44
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate multipart field types before using them.

formData.get("file") as File can actually be string | null; a string will pass the truthy check and fail later at arrayBuffer() with a 500. Validate runtime types and return 400 for malformed payloads.

Proposed patch
-    const formData = await req.formData();
-    const file = formData.get("file") as File;
-    const folder = (formData.get("folder") as string) || "uploads";
+    const formData = await req.formData();
+    const filePart = formData.get("file");
+    if (!(filePart instanceof File)) {
+      return log.respond(
+        new Response(JSON.stringify({ error: "Arquivo inválido", request_id: requestId }), {
+          status: 400,
+          headers: { ...corsHeaders, "Content-Type": "application/json" },
+        }),
+      );
+    }
+    const folderPart = formData.get("folder");
+    const folder = typeof folderPart === "string" && folderPart.trim()
+      ? folderPart
+      : "uploads";
+    const file = filePart;

Also applies to: 54-57

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/secure-upload/index.ts` around lines 41 - 44, The handler
currently assumes multipart fields are the correct types; before calling
file.arrayBuffer() or using folder, validate runtime types and return a 400 for
malformed payloads. Specifically, after const formData = await req.formData()
and where you assign const file = formData.get("file") and const folder =
(formData.get("folder") as string) || "uploads", check that file is actually a
File-like object (e.g., instanceof File or has a callable arrayBuffer method)
and that folder is a string; if not, respond with status 400 and an error
message. Apply the same runtime checks at the later usage site(s) around the
code referenced on lines 54-57 where file/folder are used, and bail out early
with 400 rather than letting file.arrayBuffer() throw a 500.

Comment on lines +41 to +44
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const file = formData.get("file") as File e formData.get("folder") as string mascaram tipos inválidos vindos do client (ex.: file pode ser string ou null). Isso pode causar exceções em runtime (ex.: file.arrayBuffer()), virando 500 ao invés de erro de request. Validar explicitamente file instanceof File e que folder é string (e, idealmente, sanitizar para evitar ../ e barras).

Copilot uses AI. Check for mistakes.
const authHeader = req.headers.get("Authorization");
let user = null;
if (authHeader) {
const { data: { user: authUser } } = await supabaseAdmin.auth.getUser(authHeader.replace('Bearer ', ''))
user = authUser
const { data: { user: authUser } } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", ""),
);
user = authUser;
}

if (!file) throw new Error('Arquivo obrigatório')
if (!file) throw new Error("Arquivo obrigatório");

const fileBuffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBuffer)
const hashHex = Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
const fileBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer);
Comment on lines +56 to +57
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an explicit file-size guard before buffering into memory.

This endpoint is anonymous/public and loads the full file into memory (arrayBuffer()), which is a straightforward DoS vector without a hard byte limit.

Proposed patch
+    const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10MB
+    if (file.size > MAX_FILE_BYTES) {
+      return log.respond(
+        new Response(JSON.stringify({ error: "Arquivo excede 10MB", request_id: requestId }), {
+          status: 413,
+          headers: { ...corsHeaders, "Content-Type": "application/json" },
+        }),
+      );
+    }
     const fileBuffer = await file.arrayBuffer();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const fileBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer);
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_BYTES) {
return log.respond(
new Response(JSON.stringify({ error: "Arquivo excede 10MB", request_id: requestId }), {
status: 413,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
);
}
const fileBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest("SHA-256", fileBuffer);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/secure-upload/index.ts` around lines 56 - 57, Add a hard
byte-limit check before buffering the uploaded file: define a MAX_UPLOAD_BYTES
constant and, inside the request handler (before calling file.arrayBuffer() /
before computing hash via crypto.subtle.digest), inspect the file.size (or
Content-Length) and immediately return a 413/appropriate error if it exceeds the
limit; only call file.arrayBuffer() and continue with hashBuffer = await
crypto.subtle.digest(...) after the size check passes.

const hashHex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

auditData = {
user_id: user?.id ?? null,
bucket: 'personalization-images',
bucket: "personalization-images",
path: `verified/${folder}/${file.name}`,
hash: hashHex,
status_code: 200,
scan_result: { message: 'Arquivo recebido para análise' }
scan_result: { message: "Arquivo recebido para análise" },
};

let isSuspicious = false
let scanDetails: any = { source: 'VirusTotal', checked_at: new Date().toISOString() }
let targetBucket = 'personalization-images'
let targetPrefix = 'verified'
const vtApiKey = Deno.env.get('VIRUSTOTAL_API_KEY')
let isSuspicious = false;
let scanDetails: Record<string, unknown> = {
source: "VirusTotal",
checked_at: new Date().toISOString(),
};
let targetBucket = "personalization-images";
let targetPrefix = "verified";
const vtApiKey = Deno.env.get("VIRUSTOTAL_API_KEY");

Comment on lines +78 to 79
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O fluxo de segurança só roda se VIRUSTOTAL_API_KEY estiver setada; caso contrário, o upload segue sem scan, apesar do propósito/rationale sugerirem que há verificação anti-malware. Para evitar um bypass silencioso em ambientes mal configurados, considerar ao menos logar um evento (ex.: security_check_skipped) e/ou falhar o request quando a chave não existir em produção.

Suggested change
const vtApiKey = Deno.env.get("VIRUSTOTAL_API_KEY");
const vtApiKey = Deno.env.get("VIRUSTOTAL_API_KEY");
const isProduction = ["production", "prod"].includes(
(Deno.env.get("NODE_ENV") ?? Deno.env.get("ENV") ?? Deno.env.get("SUPABASE_ENV") ?? "")
.toLowerCase(),
);
if (!vtApiKey) {
scanDetails = {
...scanDetails,
source: "VirusTotal",
skipped: true,
reason: "VIRUSTOTAL_API_KEY não configurada",
};
log.error("security_check_skipped", {
reason: "missing_virustotal_api_key",
environment: isProduction ? "production" : "non-production",
bucket: auditData.bucket,
path: auditData.path,
hash: auditData.hash,
});
if (isProduction) {
throw new Error("Configuração de segurança ausente: VIRUSTOTAL_API_KEY");
}
}

Copilot uses AI. Check for mistakes.
if (vtApiKey) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000)
const vtRes = await fetch(`https://www.virustotal.com/api/v3/files/${hashHex}`, {
headers: { 'x-apikey': vtApiKey },
signal: controller.signal
})
clearTimeout(timeoutId)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const vtRes = await fetch(
`https://www.virustotal.com/api/v3/files/${hashHex}`,
{
headers: { "x-apikey": vtApiKey },
signal: controller.signal,
},
);
clearTimeout(timeoutId);

if (vtRes.ok) {
const vtData = await vtRes.json()
scanDetails = { ...scanDetails, ...vtData.data.attributes.last_analysis_stats }
if (scanDetails.malicious > 0 || scanDetails.suspicious > 0) {
isSuspicious = true
scanDetails.reason = `Detectado: ${scanDetails.malicious} maliciosos, ${scanDetails.suspicious} suspeitos`
const vtData = await vtRes.json();
scanDetails = { ...scanDetails, ...vtData.data.attributes.last_analysis_stats };
const malicious = (scanDetails.malicious as number | undefined) ?? 0;
const suspicious = (scanDetails.suspicious as number | undefined) ?? 0;
if (malicious > 0 || suspicious > 0) {
isSuspicious = true;
scanDetails.reason = `Detectado: ${malicious} maliciosos, ${suspicious} suspeitos`;
} else {
scanDetails.reason = 'Arquivo limpo (base VirusTotal)'
scanDetails.reason = "Arquivo limpo (base VirusTotal)";
}
} else if (vtRes.status === 404) {
scanDetails.reason = 'Arquivo novo no VirusTotal (análise pendente). Permitido upload inicial.'
scanDetails.reason =
"Arquivo novo no VirusTotal (análise pendente). Permitido upload inicial.";
} else {
throw new Error(`Falha na API de segurança (Status: ${vtRes.status})`)
throw new Error(`Falha na API de segurança (Status: ${vtRes.status})`);
}
} catch (err: any) {
const reason = err.name === 'AbortError' ? 'Timeout na verificação (10s)' : err.message;
console.error('Security Check Failed:', reason);

await supabaseAdmin.from('file_scan_logs').insert({
} catch (err) {
const errorObj = err as { name?: string; message?: string };
const reason = errorObj.name === "AbortError"
? "Timeout na verificação (10s)"
: (errorObj.message ?? "Erro desconhecido");
log.error("security_check_failed", { err, reason });

await supabaseAdmin.from("file_scan_logs").insert({
...auditData,
status_code: 403,
scan_result: { ...scanDetails, error: true, reason: `Bloqueio preventivo: ${reason}` }
scan_result: { ...scanDetails, error: true, reason: `Bloqueio preventivo: ${reason}` },
});

return new Response(JSON.stringify({ error: `Segurança: ${reason}` }), {
status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
return log.respond(
new Response(JSON.stringify({ error: `Segurança: ${reason}`, request_id: requestId }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}),
);
}
}

if (isSuspicious) {
targetBucket = 'quarantine'
targetPrefix = 'suspect'
targetBucket = "quarantine";
targetPrefix = "suspect";
}

const fileName = `${targetPrefix}/${folder}/${Date.now()}-${Math.random().toString(36).substring(7)}.${file.name.split('.').pop()}`
const fileName = `${targetPrefix}/${folder}/${Date.now()}-${
Math.random().toString(36).substring(7)
}.${file.name.split(".").pop()}`;
const { data: uploadData, error: uploadError } = await supabaseAdmin.storage
.from(targetBucket)
.upload(fileName, fileBuffer, { contentType: file.type, upsert: false })
.upload(fileName, fileBuffer, { contentType: file.type, upsert: false });

if (uploadError) throw uploadError
if (uploadError) throw uploadError;

auditData.path = uploadData.path;
auditData.bucket = targetBucket;
auditData.status_code = isSuspicious ? 403 : 200;
auditData.scan_result = scanDetails;

await supabaseAdmin.from('file_scan_logs').insert(auditData as ScanLog)
await supabaseAdmin.from("file_scan_logs").insert(auditData as ScanLog);

if (isSuspicious) {
return new Response(JSON.stringify({ error: 'Arquivo bloqueado: Malware detectado' }), {
status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
log.warn("upload_blocked_malware", { bucket: targetBucket, path: uploadData.path });
return log.respond(
new Response(
JSON.stringify({ error: "Arquivo bloqueado: Malware detectado", request_id: requestId }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } },
),
);
}

const { data: { publicUrl } } = supabaseAdmin.storage.from(targetBucket).getPublicUrl(uploadData.path)
return new Response(JSON.stringify({ url: publicUrl, path: uploadData.path }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200
})
const { data: { publicUrl } } = supabaseAdmin.storage
.from(targetBucket)
.getPublicUrl(uploadData.path);

} catch (error: any) {
console.error('Final Error:', error.message)
log.info("upload_ok", {
bucket: targetBucket,
path: uploadData.path,
user_id: user?.id ?? null,
});

return log.respond(
new Response(
JSON.stringify({ url: publicUrl, path: uploadData.path, request_id: requestId }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 200 },
),
);
} catch (error) {
const errorObj = error as { message?: string };
log.error("upload_failed", { err: error });
if (auditData.hash) {
await supabaseAdmin.from('file_scan_logs').insert({
await supabaseAdmin.from("file_scan_logs").insert({
...auditData,
status_code: 500,
scan_result: { error: true, message: error.message }
scan_result: { error: true, message: errorObj.message ?? "Erro desconhecido" },
});
}
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500
})
return log.respond(
new Response(
JSON.stringify({ error: errorObj.message ?? "Erro desconhecido", request_id: requestId }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 },
),
Comment on lines +191 to +193
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not expose raw internal error messages in public responses.

Returning errorObj.message can leak internal details. Keep full details in logs, return a stable generic message with request_id.

Proposed patch
-        JSON.stringify({ error: errorObj.message ?? "Erro desconhecido", request_id: requestId }),
+        JSON.stringify({ error: "Falha no upload", request_id: requestId }),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
JSON.stringify({ error: errorObj.message ?? "Erro desconhecido", request_id: requestId }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 },
),
JSON.stringify({ error: "Falha no upload", request_id: requestId }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 },
),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/functions/secure-upload/index.ts` around lines 191 - 193, The
response currently includes errorObj.message which may leak internal details;
change the response body to a stable generic message (e.g., "Internal server
error") and include only the requestId for correlation, while still logging the
full error (errorObj and requestId) internally; update the JSON.stringify call
that builds the 500 response (the block using requestId, errorObj, corsHeaders)
to return { error: "Internal server error", request_id: requestId } and ensure
any logging statements record errorObj.message/stack with requestId for
debugging.

);
}
})
});
Loading