Files
bare-operating-system/packages/bare-os-booter/lib/bare-os-http-policy.js
T
2026-04-03 20:46:09 -04:00

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)
}
}