131 lines
3.5 KiB
JavaScript
131 lines
3.5 KiB
JavaScript
/**
|
|
* Optional HTTP allow/deny lists for delegated `curl` / `wget` (`BARE_OS_HTTP_ALLOWLIST`, `BARE_OS_HTTP_DENYLIST`).
|
|
* Comma- or newline-separated host globs (`*`, `?`) or `host:port` / `scheme://host` prefixes.
|
|
*/
|
|
|
|
/**
|
|
* @param {string | undefined} raw
|
|
* @returns {string[]}
|
|
*/
|
|
export function parseHostPatternList(raw) {
|
|
if (raw == null || raw === '') return []
|
|
return String(raw)
|
|
.split(/[\s,]+/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
/**
|
|
* @param {string} pattern
|
|
* @param {string} host
|
|
*/
|
|
function hostMatchesGlob(pattern, host) {
|
|
const p = pattern.toLowerCase()
|
|
const h = host.toLowerCase()
|
|
if (p === '*' || p === '*:*') return true
|
|
const esc = p
|
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
.replace(/\*/g, '.*')
|
|
.replace(/\?/g, '.')
|
|
try {
|
|
return new RegExp(`^${esc}$`).test(h)
|
|
} catch {
|
|
return h === p
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {URL} u
|
|
* @param {string[]} patterns
|
|
*/
|
|
function urlMatchesAnyPattern(u, patterns) {
|
|
const host = u.hostname
|
|
const hostPort = u.port ? `${host}:${u.port}` : host
|
|
const origin = `${u.protocol}//${hostPort}`
|
|
for (const pat of patterns) {
|
|
if (!pat) continue
|
|
if (pat.includes('://')) {
|
|
if (origin.startsWith(pat.toLowerCase())) return true
|
|
continue
|
|
}
|
|
if (pat.includes(':') && !pat.includes('*') && !pat.includes('?')) {
|
|
if (hostPort.toLowerCase() === pat.toLowerCase()) return true
|
|
continue
|
|
}
|
|
if (hostMatchesGlob(pat, host)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {string} urlStr
|
|
* @param {{ allow?: string[], deny?: string[] }} policy
|
|
* @returns {{ ok: boolean, reason?: string }}
|
|
*/
|
|
export function bareOsHttpUrlAllowed(urlStr, policy) {
|
|
let u
|
|
try {
|
|
u = new URL(urlStr)
|
|
} catch {
|
|
return { ok: false, reason: 'invalid URL' }
|
|
}
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
return { ok: false, reason: 'unsupported scheme' }
|
|
}
|
|
const deny = policy.deny || []
|
|
if (deny.length && urlMatchesAnyPattern(u, deny)) {
|
|
return { ok: false, reason: 'denylist' }
|
|
}
|
|
const allow = policy.allow || []
|
|
if (!allow.length) return { ok: true }
|
|
if (urlMatchesAnyPattern(u, allow)) return { ok: true }
|
|
return { ok: false, reason: 'not in allowlist' }
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, string | undefined>} env
|
|
*/
|
|
export function bareOsHttpPolicyFromEnv(env) {
|
|
const o = env && typeof env === 'object' ? env : {}
|
|
return {
|
|
allow: parseHostPatternList(o.BARE_OS_HTTP_ALLOWLIST),
|
|
deny: parseHostPatternList(o.BARE_OS_HTTP_DENYLIST)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrap `fetch` so requests are checked against policy (and optional audit).
|
|
* @param {typeof fetch} inner
|
|
* @param {{ allow?: string[], deny?: string[] }} policy
|
|
* @param {(info: { url: string, ok: boolean, reason?: string }) => void} [audit]
|
|
* @returns {typeof fetch}
|
|
*/
|
|
export function wrapFetchWithBareOsHttpPolicy(inner, policy, audit) {
|
|
return async (input, init) => {
|
|
const urlStr =
|
|
typeof input === 'string'
|
|
? input
|
|
: input && typeof input === 'object' && 'url' in input
|
|
? String(/** @type {{ url: string }} */ (input).url)
|
|
: String(input)
|
|
const check = bareOsHttpUrlAllowed(urlStr, policy)
|
|
if (audit) {
|
|
try {
|
|
audit({
|
|
url: urlStr.slice(0, 2048),
|
|
ok: check.ok,
|
|
reason: check.reason
|
|
})
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
if (!check.ok) {
|
|
throw new Error(
|
|
`bare-os: HTTP blocked (${check.reason || 'policy'}): ${urlStr.slice(0, 120)}`
|
|
)
|
|
}
|
|
return inner(input, init)
|
|
}
|
|
}
|