import isestr from 'wsemi/src/isestr.mjs'
//內網與保留位址之判別:本模組為其唯一事實來源
//
//用途只有一個:**把關本套件自己推導出來的網址**,不是把關呼叫端給的網址。
//這條分界是本模組存在的理由,寫在這裡以免日後被誤用到別處:
//
// 呼叫端給的網址 是他的決定。他要抓自己的內網服務是正當用法, 套件不該擋
// 套件推導出的網址 來自攻擊者可控的資料(轉址服務之query參數), 套件必須擋
//
//── 為何需要它(實測可觸發之SSRF) ──
//extractRedirectTarget會把轉址服務query參數內的網址取出, 交由fetchWeb重走完整流程。
//此前只要該網址本身合法(http/https)就照抓, 目標位址無任何檢核。實測:
//
// 呼叫端輸入 https://www.linkedin.com/redir/redirect?url=http%3A%2F%2F127.0.0.1%3AP%2Flatest%2Fmeta-data%2F
// → 本機端點確實收到請求, 其內容(含機密字串)原樣回到呼叫端
//
//**它比「呼叫端直接傳內網網址」嚴重**: 呼叫端若有白名單(只接受linkedin.com之網址),
//上面那個輸入會通過白名單, 而後套件自行把它換成內網端點去抓——套件打穿了呼叫端的防線。
//
//此前的修法只錨定了網域(擋掉「攻擊者自有網域模仿pattern」那個變體),
//對「用真正的轉址服務網址帶任意目標」這個主變體完全無效。修一個輸入而非一類。
//
//── IPv6必須真的解析,不可用字串比對(實測繞過) ──
//此前IPv4-mapped那條寫成 /::ffff:(\d+\.\d+\.\d+\.\d+)$/ ——要求點分十進位。
//但唯一的呼叫端傳進來的是 new URL(target).hostname, 而WHATWG之IPv6序列化**恆輸出十六進位**:
//
// new URL('http://[::ffff:127.0.0.1]/x').hostname → '[::ffff:7f00:1]'
//
//於是該分支對所有真實輸入恆不成立(死碼), 而以字面值寫的單元測試卻是綠的——
//**判準照實作寫、不照真實輸入寫**, 正是CLAUDE_experience.md二之2記載的形狀,
//由我在同一輪、同一個安全修補內再犯一次。實測可完整繞過:
// https://www.linkedin.com/redir/redirect?url=http%3A%2F%2F%5B%3A%3Affff%3A127.0.0.1%5D%2F...
// → 內網端點確實收到請求, 內容原樣回到呼叫端
//
//故改為把IPv6展開成8組16位元後以數值判定, 兩種書寫形態自然同解。
//測試亦須以 new URL().hostname 為輸入而非字面值(見unit-isInternalHost)
//
//── 本模組擋不住什麼(不可假裝已解決) ──
//DNS rebinding: 公開網域名解析到私有位址者, 本模組看不出來——它只看字面主機。
//要擋須解析DNS並釘住位址後再連線, 那需要抓取層配合(curl與playwright皆非本套件直接控制),
//故列為已知限制而非佯稱涵蓋
//IPv4字面位址之四段解析, 非此形態回null
function _v4(hostname) {
let m = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (!m) {
return null
}
let ns = [m[1], m[2], m[3], m[4]].map((v) => parseInt(v, 10))
if (ns.some((v) => v > 255)) {
return null
}
return ns
}
//IPv4之保留與私有網段
function _isInternalV4(ns) {
let [a, b] = ns
//0.0.0.0/8 本機; 部分網路堆疊會把0.0.0.0視為localhost
if (a === 0) {
return true
}
//127.0.0.0/8 loopback
if (a === 127) {
return true
}
//10.0.0.0/8 私有
if (a === 10) {
return true
}
//172.16.0.0/12 私有
if (a === 172 && b >= 16 && b <= 31) {
return true
}
//192.168.0.0/16 私有
if (a === 192 && b === 168) {
return true
}
//169.254.0.0/16 link-local, 雲端metadata端點(169.254.169.254)即在此段
if (a === 169 && b === 254) {
return true
}
//100.64.0.0/10 CGNAT
if (a === 100 && b >= 64 && b <= 127) {
return true
}
return false
}
//IPv6解析為8組16位元, 非此形態回null
//支援'::'省略與尾端點分十進位(如::ffff:127.0.0.1)兩種書寫
function _v6(h) {
if (!h.includes(':')) {
return null
}
//尾端若為點分十進位, 先轉成兩組16位元
let s = h
let tail = s.match(/:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (tail) {
let ns = _v4(tail[1])
if (!ns) {
return null
}
let hex = ((ns[0] << 8) | ns[1]).toString(16) + ':' + ((ns[2] << 8) | ns[3]).toString(16)
s = s.slice(0, s.length - tail[1].length) + hex
}
//以'::'切為前後兩段, 中間補零至8組
let parts = s.split('::')
if (parts.length > 2) {
return null
}
let toGroups = (v) => (v === '' ? [] : v.split(':'))
let head = toGroups(parts[0])
let rear = parts.length === 2 ? toGroups(parts[1]) : []
let groups = parts.length === 2
? [...head, ...Array(8 - head.length - rear.length).fill('0'), ...rear]
: head
if (groups.length !== 8) {
return null
}
let out = []
for (let g of groups) {
if (!/^[0-9a-f]{1,4}$/.test(g)) {
return null
}
out.push(parseInt(g, 16))
}
return out
}
//IPv6之保留與私有網段
function _isInternalV6(g) {
//:: 未指定, ::1 loopback
let allZeroHead = g.slice(0, 7).every((v) => v === 0)
if (allZeroHead && (g[7] === 0 || g[7] === 1)) {
return true
}
//fc00::/7 unique local
if ((g[0] & 0xfe00) === 0xfc00) {
return true
}
//fe80::/10 link-local
if ((g[0] & 0xffc0) === 0xfe80) {
return true
}
//末32位元還原為IPv4之三種形態: IPv4-mapped(::ffff:x)、IPv4-compatible(::x)、NAT64(64:ff9b::/96)
let toV4 = () => [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff]
let zero0to4 = g.slice(0, 5).every((v) => v === 0)
if (zero0to4 && (g[5] === 0xffff || g[5] === 0)) {
return _isInternalV4(toV4())
}
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((v) => v === 0)) {
return _isInternalV4(toV4())
}
return false
}
/**
* 判別主機名是否指向內網、迴環或保留位址
*
* 僅供把關本套件自行推導出之網址(如由轉址服務query參數提取者),
* 不可用於把關呼叫端明確給定之網址——後者是呼叫端的決定
*
* @param {String} hostname 輸入主機名字串,不含協定與埠
* @returns {Boolean} 回傳是否為內網或保留位址之布林值
* @example
*
* import isInternalHost from './src/isInternalHost.mjs'
*
* console.log(isInternalHost('169.254.169.254'), isInternalHost('127.0.0.1'), isInternalHost('localhost'))
* // => true true true
*
* console.log(isInternalHost('example.com'), isInternalHost('8.8.8.8'))
* // => false false
*
*/
function isInternalHost(hostname) {
if (!isestr(hostname)) {
return true
}
let h = hostname.toLowerCase().trim()
//IPv6以中括號包覆時先去除
if (h.startsWith('[') && h.endsWith(']')) {
h = h.slice(1, -1)
}
//localhost與其子網域
if (h === 'localhost' || h.endsWith('.localhost')) {
return true
}
//mDNS與常見內網後綴
if (h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.localdomain')) {
return true
}
//單標籤主機名(不含點)無法是公開站台, 於推導路徑一律視為內網
//如http://intranet/或http://metadata/, 是內網SSRF之常見形態
if (!h.includes('.') && !h.includes(':')) {
return true
}
let ns = _v4(h)
if (ns) {
return _isInternalV4(ns)
}
//IPv6: 展開為8組16位元後以數值判定, 不做字串比對(理由見檔頭)
let g6 = _v6(h)
if (g6) {
return _isInternalV6(g6)
}
//含冒號卻解不出IPv6者形態不明, 於推導路徑一律視為內網(寧可不提取)
if (h.includes(':')) {
return true
}
return false
}
export default isInternalHost