-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [HIGH] Fix SSRF vulnerability in /api/lookup/url #30
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,8 +9,43 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { NextRequest, NextResponse } from 'next/server'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { lookup } from 'dns/promises'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { SourceType } from '@/types'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Utility to check if an IP is private/local | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function isPrivateIP(ip: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // IPv4 Private/Reserved Ranges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.startsWith('10.') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.startsWith('127.') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.startsWith('169.254.') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.startsWith('192.168.') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip === '0.0.0.0' || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip === '255.255.255.255' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 172.16.0.0/12 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (ip.startsWith('172.')) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const secondOctet = parseInt(ip.split('.')[1], 10); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (secondOctet >= 16 && secondOctet <= 31) return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // IPv6 Private/Reserved Ranges | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip === '::1' || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip === '::' || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.toLowerCase().startsWith('fc00:') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.toLowerCase().startsWith('fd00:') || | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ip.toLowerCase().startsWith('fe80:') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
13
to
+46
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { SourceType } from '@/types'; | |
| // Utility to check if an IP is private/local | |
| function isPrivateIP(ip: string): boolean { | |
| // IPv4 Private/Reserved Ranges | |
| if ( | |
| ip.startsWith('10.') || | |
| ip.startsWith('127.') || | |
| ip.startsWith('169.254.') || | |
| ip.startsWith('192.168.') || | |
| ip === '0.0.0.0' || | |
| ip === '255.255.255.255' | |
| ) { | |
| return true; | |
| } | |
| // 172.16.0.0/12 | |
| if (ip.startsWith('172.')) { | |
| const secondOctet = parseInt(ip.split('.')[1], 10); | |
| if (secondOctet >= 16 && secondOctet <= 31) return true; | |
| } | |
| // IPv6 Private/Reserved Ranges | |
| if ( | |
| ip === '::1' || | |
| ip === '::' || | |
| ip.toLowerCase().startsWith('fc00:') || | |
| ip.toLowerCase().startsWith('fd00:') || | |
| ip.toLowerCase().startsWith('fe80:') | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| import * as ipaddr from 'ipaddr.js'; | |
| import type { SourceType } from '@/types'; | |
| // Utility to check if an IP is private/local | |
| function isPrivateIP(ip: string): boolean { | |
| try { | |
| const addr = ipaddr.parse(ip); | |
| if (addr.kind() === 'ipv4') { | |
| return ( | |
| addr.match(ipaddr.parseCIDR('10.0.0.0/8')) || | |
| addr.match(ipaddr.parseCIDR('127.0.0.0/8')) || | |
| addr.match(ipaddr.parseCIDR('169.254.0.0/16')) || | |
| addr.match(ipaddr.parseCIDR('172.16.0.0/12')) || | |
| addr.match(ipaddr.parseCIDR('192.168.0.0/16')) || | |
| addr.toString() === '0.0.0.0' || | |
| addr.toString() === '255.255.255.255' | |
| ); | |
| } | |
| return ( | |
| addr.toString() === '::1' || | |
| addr.toString() === '::' || | |
| addr.match(ipaddr.parseCIDR('fc00::/7')) || | |
| addr.match(ipaddr.parseCIDR('fe80::/10')) | |
| ); | |
| } catch { | |
| return false; | |
| } |
Copilot
AI
Apr 27, 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.
Even with the pre-fetch DNS/IP validation, fetch() will follow redirects by default. A request to a public URL that 3xx-redirects to an internal host/IP would bypass the current guard because only the original hostname is validated. Consider using redirect: 'manual' and explicitly validating (and optionally following) redirect targets with the same SSRF checks.
Copilot
AI
Apr 27, 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.
dns.lookup(parsedUrl.hostname) only returns a single address. If the hostname has multiple A/AAAA records (or changes between the check and fetch), the pre-check can pass on a public IP while the actual fetch resolves to a private/loopback/link-local IP. Consider calling lookup with { all: true } and rejecting if any resolved address is private, and/or pinning resolution at request time via a custom dispatcher/agent if you need stronger DNS-rebinding resistance.
| const { address } = await lookup(parsedUrl.hostname); | |
| if (isPrivateIP(address)) { | |
| return NextResponse.json( | |
| { success: false, error: 'Access to internal networks is forbidden' }, | |
| { status: 403 } | |
| ); | |
| } | |
| resolvedIp = address; | |
| const addresses = await lookup(parsedUrl.hostname, { all: true }); | |
| if (addresses.length === 0) { | |
| return NextResponse.json( | |
| { success: false, error: 'Could not resolve hostname' }, | |
| { status: 400 } | |
| ); | |
| } | |
| if (addresses.some(({ address }) => isPrivateIP(address))) { | |
| return NextResponse.json( | |
| { success: false, error: 'Access to internal networks is forbidden' }, | |
| { status: 403 } | |
| ); | |
| } | |
| resolvedIp = addresses[0].address; |
Copilot
AI
Apr 27, 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.
resolvedIp is assigned but never used. Either remove it, or use it for something meaningful (e.g., logging/telemetry), otherwise it becomes dead code and can mislead readers into thinking the resolved IP is being used/pinned for the subsequent request.
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.
The new SSRF tests cover invalid protocol, loopback IPv4, and DNS failures, but they donβt exercise important bypass cases introduced by the implementation: IPv6 ULA/link-local (e.g.
fdxx::/8,fe8x::/10), multi-address DNS results, and redirects to internal networks. Adding test cases for these would help ensure the protections actually hold for the intended threat model.