-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [HIGH] Fix Server-Side Request Forgery (SSRF) in URL Lookup API #23
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 all commits
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 |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| */ | ||
|
|
||
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { lookup } from 'dns/promises'; | ||
| import type { SourceType } from '@/types'; | ||
|
|
||
| interface MetadataResult { | ||
|
|
@@ -77,6 +78,53 @@ export async function POST(request: NextRequest): Promise<NextResponse<APIRespon | |
| ); | ||
| } | ||
|
|
||
|
|
||
| // SSRF Protection: Prevent requests to internal networks | ||
| try { | ||
| let hostname = parsedUrl.hostname; | ||
|
|
||
| // Strip IPv6 brackets for lookup | ||
| if (hostname.startsWith('[') && hostname.endsWith(']')) { | ||
| hostname = hostname.slice(1, -1); | ||
| } | ||
|
|
||
| if (hostname === 'localhost') { | ||
| throw new Error('Forbidden IP'); | ||
| } | ||
|
|
||
| // Try to resolve the hostname. If it's an IP literal, lookup returns it. | ||
| const { address } = await lookup(hostname); | ||
|
|
||
| // Comprehensive regex for private IPv4, IPv6 loopback, IPv6 unique local, IPv6 link local, etc. | ||
| const privateIPRegex = /^(::f{4}:)?(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3}|0\.0\.0\.0)$/i; | ||
|
|
||
| // Check IPv4 private and basic IPv6 local | ||
| const isPrivateIPv4 = privateIPRegex.test(address); | ||
| const isLocalIPv6 = address === '::1' || address === '::' || address.toLowerCase().startsWith('fe80:') || address.toLowerCase().match(/^f[cd][0-9a-f]{2}:/i); | ||
|
Comment on lines
+98
to
+103
|
||
|
|
||
| if (isPrivateIPv4 || isLocalIPv6) { | ||
| return NextResponse.json( | ||
|
Comment on lines
+82
to
+106
|
||
| { success: false, error: 'Forbidden: Cannot fetch from internal networks' }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
|
Comment on lines
+82
to
+110
|
||
| } catch (err: any) { | ||
| if (err.message === 'Forbidden IP') { | ||
| return NextResponse.json( | ||
| { success: false, error: 'Forbidden: Cannot fetch from internal networks' }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
| // If the domain doesn't resolve (ENOTFOUND), we still want to block if it was an IPv6 literal that failed lookup | ||
| if (err.code === 'ENOTFOUND' && parsedUrl.hostname.startsWith('[')) { | ||
| return NextResponse.json( | ||
| { success: false, error: 'Forbidden: Cannot fetch from internal networks' }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
|
Comment on lines
+118
to
+124
|
||
| // If it's a general lookup failure for a domain, let fetch handle it to give a natural error | ||
| } | ||
|
|
||
| // Detect the source type from the URL and grab any type-specific hints | ||
| const detection = detectSourceTypeFromUrl(parsedUrl); | ||
|
|
||
|
|
||
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.
lookup(hostname)returns a single address, but many hostnames resolve to multiple A/AAAA records;fetch()may connect to a different record than the one you validated (even without DNS rebinding). Uselookup(hostname, { all: true })(and ideally reject if any resolved address is non-public), or otherwise ensure the validated address is the one actually used for the outbound connection.