-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [HIGH] Fix SSRF in URL lookup #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |||||||||
|
|
||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||
| import type { SourceType } from '@/types'; | ||||||||||
| import { lookup } from 'dns/promises'; | ||||||||||
|
|
||||||||||
| interface MetadataResult { | ||||||||||
| title?: string; | ||||||||||
|
|
@@ -54,6 +55,45 @@ interface APIResponse { | |||||||||
| error?: string; | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Check if an IP address is private/local. | ||||||||||
| */ | ||||||||||
| function isPrivateIP(ip: string): boolean { | ||||||||||
|
|
||||||||||
| // IPv6 unspecified address | ||||||||||
| if (ip === '::') return true; | ||||||||||
|
|
||||||||||
| // IPv6 loopback | ||||||||||
|
|
||||||||||
| if (ip === '::1') return true; | ||||||||||
|
|
||||||||||
| // IPv6 unique local address | ||||||||||
| if (ip.match(/^(fc|fd)/i)) return true; | ||||||||||
|
|
||||||||||
| // IPv6 link-local address | ||||||||||
| if (ip.match(/^fe80/i)) return true; | ||||||||||
|
|
||||||||||
| // IPv4 mapping | ||||||||||
| if (ip.startsWith('::ffff:')) { | ||||||||||
| ip = ip.replace('::ffff:', ''); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| const parts = ip.split('.'); | ||||||||||
| if (parts.length !== 4) return false; | ||||||||||
|
|
||||||||||
| const numParts = parts.map(Number); | ||||||||||
|
|
||||||||||
| return ( | ||||||||||
| numParts[0] === 10 || // 10.0.0.0/8 | ||||||||||
| (numParts[0] === 172 && numParts[1] >= 16 && numParts[1] <= 31) || // 172.16.0.0/12 | ||||||||||
| (numParts[0] === 192 && numParts[1] === 168) || // 192.168.0.0/16 | ||||||||||
| numParts[0] === 127 || // 127.0.0.0/8 | ||||||||||
| numParts[0] === 0 || // 0.0.0.0/8 | ||||||||||
| (numParts[0] === 169 && numParts[1] === 254) // 169.254.0.0/16 | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export async function POST(request: NextRequest): Promise<NextResponse<APIResponse>> { | ||||||||||
| try { | ||||||||||
| const body = await request.json(); | ||||||||||
|
|
@@ -77,9 +117,27 @@ export async function POST(request: NextRequest): Promise<NextResponse<APIRespon | |||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
||||||||||
| // Detect the source type from the URL and grab any type-specific hints | ||||||||||
| const detection = detectSourceTypeFromUrl(parsedUrl); | ||||||||||
|
|
||||||||||
| // SSRF Protection: Pre-fetch DNS validation | ||||||||||
| try { | ||||||||||
| const { address } = await lookup(parsedUrl.hostname); | ||||||||||
| if (isPrivateIP(address)) { | ||||||||||
|
Comment on lines
+126
to
+127
|
||||||||||
| const { address } = await lookup(parsedUrl.hostname); | |
| if (isPrivateIP(address)) { | |
| const addresses = await lookup(parsedUrl.hostname, { all: true, verbatim: true }); | |
| if (addresses.length === 0 || addresses.some(({ address }) => isPrivateIP(address))) { |
Copilot
AI
Apr 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This pre-fetch DNS check can be bypassed via redirects (fetch follows redirects by default) and DNS rebinding (fetch may resolve again later). To make the SSRF mitigation effective, disable automatic redirects and re-validate each Location hop (or use a client/dispatcher that pins the connection to the validated IP while preserving SNI/Host).
Copilot
AI
Apr 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding dns/promises lookup makes this handler (and existing unit tests) depend on real DNS/network conditions. Please mock dns/promises in unit tests and add coverage for the new SSRF behavior (e.g., 127.0.0.1/::1 rejected with 403, and lookup failures returning 400).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For SSRF hardening, explicitly allow only
http:/https:URLs (reject other schemes with a clear 400) before running DNS resolution. This avoids cases likefile:/empty hostname falling into the generic "Failed to resolve hostname" path.