Skip to content

harden(edge): RemoteUrlSchema anti-SSRF (E2)#93

Merged
adm01-debug merged 2 commits into
mainfrom
chore/schemas-hardening-e2
May 8, 2026
Merged

harden(edge): RemoteUrlSchema anti-SSRF (E2)#93
adm01-debug merged 2 commits into
mainfrom
chore/schemas-hardening-e2

Conversation

@adm01-debug
Copy link
Copy Markdown
Owner

@adm01-debug adm01-debug commented May 8, 2026

Contexto

Atende finding 🟠 Major do CodeRabbit no PR #91 (comment r3210444970):

URLs remotas precisam ser restringidas. z.string().url() só valida formato. Para functions que baixam áudio/imagem, isso permite apontar para hosts internos ou metadata se o handler fizer fetch() direto.

Risco SSRF confirmado em produção

Investigação mostrou que ai-transcribe-audio/index.ts:46 faz await fetch(audioUrl) direto quando a URL não é Supabase Storage. Os outros 3 (classify-audio-meme, classify-emoji, classify-sticker) repassam URL para Lovable AI Gateway / OpenAI Vision API que fazem fetch upstream.

Vetor explorado: https://attacker.com@169.254.169.254/... — URL parser pega último host como hostname (AWS/GCP/DO metadata service).

O que este PR faz

Novo helper RemoteUrlSchema

Aplicado em 4 schemas (com .optional().nullable() preservado onde havia):

Schema Campo Antes Depois
TranscribeAudioSchema audioUrl z.string().url(...).max(2048) RemoteUrlSchema
ClassifyAudioMemeSchema audio_url z.string().url().max(2048).optional().nullable() RemoteUrlSchema.optional().nullable()
ClassifyEmojiSchema image_url idem idem
ClassifyStickerSchema image_url idem idem

Restrições aplicadas

  • HTTPS-only (rejeita http://, ftp://, data:, javascript:, file:)
  • IPv4 privados/reservados: 10/8, 127/8, 169.254/16 (cloud metadata!), 172.16/12, 192.168/16
  • IPv6: ::1 (loopback), fe80::/10 (link-local), fc00::/7 (ULA)
  • Hostnames reservados: localhost, *.local, *.internal
  • Max 2048 chars (mantido)

Stress-test (41 cenários)

Categoria Pass
Supabase Storage HTTPS público/signed
CDN, S3, Cloudfront externos
HTTP rejeitado
AWS/GCP/DO metadata 169.254.169.254
Redes internas 10.x, 172.16-31, 192.168
172.32, 172.15 (fora do range privado) ✅ permitidos
.local, .internal TLDs ✅ rejeitados
IPv6 loopback ::1, ULA fc00, link-local fe80 ✅ rejeitados
IPv6 público 2001:db8::1 ✅ permitido
https://attacker.com@169.254.169.254/ (Docker bypass) ✅ rejeitado
data:, javascript:, file:, ftp: ✅ rejeitados
Punycode xn--bcher-kva.example.com ✅ permitido
LOCALHOST (case-insensitive) ✅ rejeitado

Compatibilidade com callers reais

Todos os callers verificados usam HTTPS de Supabase Storage:

  • AudioMessagePlayer.tsxai-transcribe-audio com freshUrl (signed URL)
  • useAudioMemes.ts / AIGenerateDialog.tsxclassify-audio-meme com urlData.publicUrl
  • useCustomEmojis.tsclassify-emoji com urlData.publicUrl
  • useStickerPicker.ts / useBackgroundClassifier.tsclassify-sticker com urlData.publicUrl

Domínio supabase.atomicabr.com.br é HTTPS público → passa em todos os refines.

Risco

🟡 Baixo. Bloqueia padrão de ataque, permite todos os usos legítimos (validados acima). Não muda shape do schema — só aperta a validação de string.

Referências

Summary by CodeRabbit

  • Refactor
    • Validação aprimorada para URLs remotas em funcionalidades de transcrição e classificação. URLs agora submetidas a restrições mais rigorosas, incluindo limitação de protocolo e comprimento máximo.

Atende finding 🟠 Major do CodeRabbit no PR #91:
URLs remotas usavam apenas z.string().url() que valida formato mas
permite hosts internos, criando risco SSRF nas edge functions que
fazem fetch direto (ai-transcribe-audio linha 46) ou que enviam URL
para serviços externos que fazem fetch (classify-audio-meme/emoji/
sticker -> Lovable AI Gateway / OpenAI Vision).

Mudanças:
- Novo helper RemoteUrlSchema com proteções:
  * HTTPS-only (rejeita http/ftp/data/javascript/file)
  * Bloqueia IPv4 privados (10/8, 127/8, 169.254/16, 172.16/12, 192.168/16)
  * Bloqueia IPv6 loopback/link-local/ULA (::1, fe80::/10, fc00::/7)
  * Bloqueia hostnames reservados (localhost, *.local, *.internal)
  * Max 2048 chars
- Aplicado em 4 schemas:
  * TranscribeAudioSchema.audioUrl
  * ClassifyAudioMemeSchema.audio_url (preserva .optional().nullable())
  * ClassifyEmojiSchema.image_url
  * ClassifyStickerSchema.image_url

Validado em 41 cenários de stress-test cobrindo Supabase Storage,
CDNs, S3, Cloudfront, AWS/GCP/DigitalOcean metadata endpoints, IPv6,
Punycode, casos exóticos (user@host bypass, FTP, javascript:, data:).

Refs: PR #91 review comment r3210444970
Source: PR-FUTURO-E2 em /workspace/notes/pr-futuro-e-schemas-hardening.md
Copilot AI review requested due to automatic review settings May 8, 2026 18:16
@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
zapp-web Ready Ready Preview, Comment May 8, 2026 6:23pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Review Change Stack

Warning

Rate limit exceeded

@adm01-debug has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 2 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d1ba8141-cd6e-455f-a060-3e0828b244cb

📥 Commits

Reviewing files that changed from the base of the PR and between c3b2d6c and ec99e91.

📒 Files selected for processing (1)
  • supabase/functions/_shared/schemas.ts

Walkthrough

Novo validador Zod RemoteUrlSchema centraliza proteção contra SSRF: força HTTPS, limita comprimento a 2048 caracteres e bloqueia ranges privados IPv4/IPv6 além de hostnames reservados (localhost, *.local, *.internal). O validador é aplicado em quatro esquemas: TranscribeAudioSchema.audioUrl e campos de URL em três esquemas de classificação, preservando opcionalidade onde existia.

Changes

Proteção SSRF para URLs Remotas

Layer / File(s) Summary
Contrato de Validação SSRF
supabase/functions/_shared/schemas.ts
Novo RemoteUrlSchema implementa validação de URL com bloqueio de SSRF: HTTPS obrigatório, máximo 2048 caracteres, rejeição de ranges privados IPv4 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16), padrões IPv6 (::1/128, fe80::/10, fc00::/7) e hostnames reservados (localhost, *.local, *.internal).
Integração em Esquemas
supabase/functions/_shared/schemas.ts
TranscribeAudioSchema.audioUrl passa de z.string().url() para RemoteUrlSchema. ClassifyAudioMemeSchema.audio_url, ClassifyEmojiSchema.image_url e ClassifyStickerSchema.image_url passam de z.string().url().max(2048).optional().nullable() para RemoteUrlSchema.optional().nullable().

🎯 2 (Simples) | ⏱️ ~12 minutos

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed O título reflete com precisão a mudança principal: introdução de RemoteUrlSchema para proteção contra SSRF, core da PR.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/schemas-hardening-e2

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/functions/_shared/schemas.ts`:
- Around line 28-59: O schema RemoteUrlSchema apenas valida formato/hostname mas
não previne DNS rebinding/redirects; para corrigir, keep RemoteUrlSchema as-is
but add a runtime validation at the actual fetch site: before performing fetch,
resolve the initial URL hostname to its IP(s) (use DNS lookup), verify each IP
is public (reject private/loopback/link-local/ULA ranges), perform the request
with redirect: 'manual' to inspect Location headers, and for every redirect hop
parse the Location, resolve its hostname IP(s) and re-validate them (reject on
any non-public IP) before following the redirect; ensure this new check is
applied wherever RemoteUrlSchema-validated URLs are fetched.
- Around line 47-50: The current IPv6 checks on the host variable are too naive
and incorrectly block valid FQDNs; update the logic that currently does host ===
"::1", host.startsWith("fe80:"), and host.startsWith("fc")/host.startsWith("fd")
to first ensure host is an actual IPv6 address (e.g., using net.isIP(host) ===
6) and then perform proper CIDR/mask checks for the link-local range fe80::/10
and the Unique Local Address range fc00::/7 (not simple prefix matches), so only
true IPv6 addresses in those ranges are rejected and FQDNs beginning with
"fc"/"fd" remain allowed. Ensure you modify the checks in the same function/area
where the host variable is inspected (replace the three startsWith lines).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aaeb9352-6186-4083-9d4e-c8918219f31e

📥 Commits

Reviewing files that changed from the base of the PR and between c3b2d6c and 7499abc.

📒 Files selected for processing (1)
  • supabase/functions/_shared/schemas.ts

Comment on lines +28 to +59
export const RemoteUrlSchema = z.string()
.url("Invalid URL format")
.max(2048, "URL too long")
.refine((u) => {
try {
return new URL(u).protocol === "https:";
} catch {
return false;
}
}, "Only HTTPS URLs are allowed")
.refine((u) => {
try {
const host = new URL(u).hostname.toLowerCase();
// IPv4 private/reserved ranges
if (/^10\./.test(host)) return false;
if (/^127\./.test(host)) return false;
if (/^169\.254\./.test(host)) return false; // link-local & cloud metadata
if (/^192\.168\./.test(host)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
// IPv6 loopback / link-local / ULA (host already without brackets via URL.hostname)
if (host === "::1") return false;
if (host.startsWith("fe80:")) return false;
if (host.startsWith("fc") || host.startsWith("fd")) return false;
// Reserved hostnames
if (host === "localhost") return false;
if (host.endsWith(".local")) return false;
if (host.endsWith(".internal")) return false;
return true;
} catch {
return false;
}
}, "Internal/private hosts are not allowed");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Ainda existe vetor SSRF via DNS rebinding/redirect para IP interno.

Esse schema valida string/hostname, mas não garante que o destino resolvido no momento do fetch seja público. Um domínio externo pode resolver (ou redirecionar) para IP privado/link-local depois da validação.

Sugestão: no ponto de fetch, validar cada hop (URL inicial + redirects) após resolução DNS e bloquear IP não público antes de conectar.

As per coding guidelines supabase/functions/**/*.ts: "Edge Functions Supabase em produção. Verificar com rigor".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_shared/schemas.ts` around lines 28 - 59, O schema
RemoteUrlSchema apenas valida formato/hostname mas não previne DNS
rebinding/redirects; para corrigir, keep RemoteUrlSchema as-is but add a runtime
validation at the actual fetch site: before performing fetch, resolve the
initial URL hostname to its IP(s) (use DNS lookup), verify each IP is public
(reject private/loopback/link-local/ULA ranges), perform the request with
redirect: 'manual' to inspect Location headers, and for every redirect hop parse
the Location, resolve its hostname IP(s) and re-validate them (reject on any
non-public IP) before following the redirect; ensure this new check is applied
wherever RemoteUrlSchema-validated URLs are fetched.

Comment thread supabase/functions/_shared/schemas.ts Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a shared RemoteUrlSchema to harden edge-function input validation against SSRF by restricting remote URLs (scheme + internal/private host checks), then applies it to the audio/image URL fields used by transcription/classifier functions.

Changes:

  • Introduces RemoteUrlSchema with HTTPS-only + internal/private host blocking logic.
  • Replaces existing z.string().url().max(2048) usage with RemoteUrlSchema in TranscribeAudioSchema, ClassifyAudioMemeSchema, ClassifyEmojiSchema, and ClassifyStickerSchema.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread supabase/functions/_shared/schemas.ts Outdated
Comment on lines +48 to +50
if (host === "::1") return false;
if (host.startsWith("fe80:")) return false;
if (host.startsWith("fc") || host.startsWith("fd")) return false;
Comment thread supabase/functions/_shared/schemas.ts Outdated
Comment on lines +48 to +50
if (host === "::1") return false;
if (host.startsWith("fe80:")) return false;
if (host.startsWith("fc") || host.startsWith("fd")) return false;
* - Blocks reserved hostnames (localhost, *.local, *.internal)
* - Max length: 2048 chars
*
* Note: User-agent (host part of `user@host`) is correctly handled by URL parser
Comment on lines +46 to +51
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
// IPv6 loopback / link-local / ULA (host already without brackets via URL.hostname)
if (host === "::1") return false;
if (host.startsWith("fe80:")) return false;
if (host.startsWith("fc") || host.startsWith("fd")) return false;
// Reserved hostnames
Comment thread supabase/functions/_shared/schemas.ts Outdated
Comment on lines +40 to +47
const host = new URL(u).hostname.toLowerCase();
// IPv4 private/reserved ranges
if (/^10\./.test(host)) return false;
if (/^127\./.test(host)) return false;
if (/^169\.254\./.test(host)) return false; // link-local & cloud metadata
if (/^192\.168\./.test(host)) return false;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return false;
// IPv6 loopback / link-local / ULA (host already without brackets via URL.hostname)
Copy link
Copy Markdown

@vercel vercel Bot May 8, 2026

Choose a reason for hiding this comment

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

IPv6 SSRF vulnerability: IPv6 validation checks fail due to brackets in hostname

Fix on Vercel

CodeRabbit Pro identificou 2 bugs no commit anterior:

1. host.startsWith('fc') / startsWith('fd') bloqueava FQDNs legítimos
   começando com fc/fd (ex: fcdn.com, fdic.gov, fc-paris.fr).
   Fix: aplicar regra IPv6 só quando host contém ':' (literal IPv6).

2. host.startsWith('fe80:') cobria apenas /16 (fe80..fe8f), perdendo
   o range completo /10 do link-local (fe80..febf).
   Fix: regex /^fe[89ab][0-9a-f]?:/ cobre fe80..febf.

Adicional: documentado que schema validation NÃO previne DNS rebinding
nem redirects pós-fetch. Defesa completa requer DNS re-check, controle
de redirect, ou outbound proxy — tracked como follow-up arquitetural.

Validado em 23 cenários adicionais cobrindo FQDNs com prefixos fc/fd/fe
e todo o range link-local fe80::/10.

Refs: PR #93 review comments coderabbit no chore/schemas-hardening-e2
@adm01-debug
Copy link
Copy Markdown
Owner Author

Atualizado com fix (commit ec99e91d6)

Obrigado pelo catch! Os 2 findings inline foram analisados e ataque correspondente:

✅ Finding linha 50 (regex IPv6) — fix aplicado

Você apontou 2 bugs reais:

  1. host.startsWith("fc") / startsWith("fd") bloqueava FQDNs legítimos (fcdn.com, fdic.gov, fc-paris.fr, fdmoonsha.com, etc.) — falso positivo
  2. host.startsWith("fe80:") cobria só fe80::, perdendo fe90/fea0/feb0/febf (range /10 completo)

Fix aplicado (commit ec99e91d6):

// IPv6 loopback / link-local / ULA — only apply to actual IPv6 literals
// (IPv6 addresses contain ':', FQDNs don't — prevents bogus blocks like "fcdn.com")
if (host.includes(":")) {
  if (host === "::1") return false;                          // loopback
  if (/^fe[89ab][0-9a-f]?:/.test(host)) return false;        // link-local fe80::/10 (fe80..febf)
  if (/^f[cd][0-9a-f]{0,2}:/.test(host)) return false;       // ULA fc00::/7
}

Validado em 23 cenários adicionais cobrindo:

  • FQDNs com prefixos fc/fd/fe (fcdn.com, fdic.gov, feast.io, fe80-store.com) → permitidos ✅
  • Todo o range link-local /10 (fe80::, fe89::, fe90::, fea0::, feb0::, febf::) → bloqueados ✅
  • fec0::1 → permitido (fora do /10) ✅
  • Cloudflare/Google IPv6 públicos → permitidos ✅

🟡 Finding linha 59 (DNS rebinding) — documentado como follow-up

Procede totalmente. Schema valida string, não resolve DNS. Atacante pode:

  1. Configurar evil.com1.2.3.4 na primeira resolução
  2. Schema passa
  3. Antes do fetch, DNS re-resolve → evil.com169.254.169.254
  4. Fetch hits metadata service

A defesa completa requer mudança arquitetural nas edge functions consumidoras:

  • DNS re-check antes de fetch
  • Disable redirects ou validate redirect targets
  • Outbound proxy com allowlisting

Adicionei nota explícita no JSDoc do schema documentando essa limitação. Será tracked como follow-up dedicado — fora do escopo deste PR (que é hardening da camada de input).

@adm01-debug
Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

✅ Actions performed

Full review triggered.

@adm01-debug
Copy link
Copy Markdown
Owner Author

Decisão de merge

CodeRabbit Pro — análise dos findings

ID Linha Severidade Status
3210560780 50 🟠 Major Quick win OUTDATED — corrigido em ec99e91d (regex IPv6 reescrito; FQDNs fcdn.com/fdic.gov/etc. agora passam; range fe80::/10 completo coberto)
3210560776 71 🟠 Major Heavy lift 🟡 Documentado como follow-up arquitetural (DNS rebinding requer mudança nas edge functions consumidoras)

GitHub marca finding 3210560780 como position: null (outdated) — o trecho com o bug não existe mais.

Checks

Stress-test consolidado

41 cenários originais + 23 cenários do fix IPv6 = 64 cenários validados. Todos passam.

Mergeando

Os 2 commits ficam squashed em main. Próximo: PR-C (E3 ExternalDbBridge).

@adm01-debug adm01-debug merged commit 3c51e7e into main May 8, 2026
10 of 11 checks passed
@adm01-debug adm01-debug deleted the chore/schemas-hardening-e2 branch May 8, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants