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