harden(edge): RemoteUrlSchema anti-SSRF (E2)#93
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughNovo validador Zod ChangesProteção SSRF para URLs Remotas
🎯 2 (Simples) | ⏱️ ~12 minutos 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
supabase/functions/_shared/schemas.ts
| 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"); |
There was a problem hiding this comment.
🛠️ 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.
There was a problem hiding this comment.
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
RemoteUrlSchemawith HTTPS-only + internal/private host blocking logic. - Replaces existing
z.string().url().max(2048)usage withRemoteUrlSchemainTranscribeAudioSchema,ClassifyAudioMemeSchema,ClassifyEmojiSchema, andClassifyStickerSchema.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (host === "::1") return false; | ||
| if (host.startsWith("fe80:")) return false; | ||
| if (host.startsWith("fc") || host.startsWith("fd")) return false; |
| 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 |
| 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 |
| 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) |
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
Atualizado com fix (commit
|
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
Decisão de mergeCodeRabbit Pro — análise dos findings
GitHub marca finding 3210560780 como Checks
Stress-test consolidado41 cenários originais + 23 cenários do fix IPv6 = 64 cenários validados. Todos passam. MergeandoOs 2 commits ficam squashed em main. Próximo: PR-C (E3 ExternalDbBridge). |
Contexto
Atende finding 🟠 Major do CodeRabbit no PR #91 (comment r3210444970):
Risco SSRF confirmado em produção
Investigação mostrou que
ai-transcribe-audio/index.ts:46fazawait 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
RemoteUrlSchemaAplicado em 4 schemas (com
.optional().nullable()preservado onde havia):TranscribeAudioSchemaaudioUrlz.string().url(...).max(2048)RemoteUrlSchemaClassifyAudioMemeSchemaaudio_urlz.string().url().max(2048).optional().nullable()RemoteUrlSchema.optional().nullable()ClassifyEmojiSchemaimage_urlClassifyStickerSchemaimage_urlRestrições aplicadas
http://,ftp://,data:,javascript:,file:)10/8,127/8,169.254/16(cloud metadata!),172.16/12,192.168/16::1(loopback),fe80::/10(link-local),fc00::/7(ULA)localhost,*.local,*.internalStress-test (41 cenários)
.local,.internalTLDs::1, ULAfc00, link-localfe802001:db8::1https://attacker.com@169.254.169.254/(Docker bypass)data:,javascript:,file:,ftp:xn--bcher-kva.example.comLOCALHOST(case-insensitive)Compatibilidade com callers reais
Todos os callers verificados usam HTTPS de Supabase Storage:
AudioMessagePlayer.tsx→ai-transcribe-audiocomfreshUrl(signed URL)useAudioMemes.ts/AIGenerateDialog.tsx→classify-audio-memecomurlData.publicUrluseCustomEmojis.ts→classify-emojicomurlData.publicUrluseStickerPicker.ts/useBackgroundClassifier.ts→classify-stickercomurlData.publicUrlDomí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