|
1 | | -// Egress guard MECHANISM only. Wraps the global `fetch` so the app's outbound requests |
2 | | -// can be screened; the block DECISION is delegated to a caller-supplied `shouldBlock` |
3 | | -// predicate (which the runtime builds from egress-phase rules — see defaults.js). |
4 | | -// No policy is hardcoded here: what counts as "internal"/disallowed lives in rules. |
5 | | -// WinterCG — works on Node 18+, Cloudflare Workers, Bun, Deno. |
| 1 | +// Egress guard MECHANISM only. Wraps the app's outbound calls so they can be screened; the |
| 2 | +// block DECISION is delegated to a caller-supplied `shouldBlock` predicate (which the runtime |
| 3 | +// builds from egress-phase rules — see defaults.js). No policy is hardcoded here. |
| 4 | +// WinterCG-first: always wraps the global `fetch` (Node 18+, Workers, Bun, Deno). On Node it |
| 5 | +// ALSO patches `node:http`/`node:https` so outbound calls made via those modules (axios, got, |
| 6 | +// the raw http client, …) — which never touch `fetch` — are screened too. |
6 | 7 |
|
7 | 8 | /** |
8 | 9 | * @param {{ shouldBlock: (url:string, host:string|null, method:string)=>boolean, |
9 | 10 | * onBlock?: (info:{url:string,host:string|null,method:string})=>void }} opts |
10 | | - * @returns {() => void} uninstall (restores the original fetch) |
| 11 | + * @returns {Promise<() => void>} uninstall (restores every patched surface) |
11 | 12 | */ |
12 | | -export function installEgressGuard({ shouldBlock, onBlock } = {}) { |
13 | | - const original = globalThis.fetch; |
14 | | - if (typeof original !== 'function' || original.__patchstackGuarded || typeof shouldBlock !== 'function') { |
15 | | - return () => {}; |
| 13 | +export async function installEgressGuard({ shouldBlock, onBlock } = {}) { |
| 14 | + const restores = []; |
| 15 | + if (typeof shouldBlock !== 'function') return () => {}; |
| 16 | + |
| 17 | + const block = (url, host, method) => { |
| 18 | + if (!shouldBlock(url, host, method)) return false; |
| 19 | + onBlock?.({ url, host, method }); |
| 20 | + return true; |
| 21 | + }; |
| 22 | + |
| 23 | + // 1. global fetch — synchronous, so it's active the instant this returns (no startup race). |
| 24 | + const originalFetch = globalThis.fetch; |
| 25 | + if (typeof originalFetch === 'function' && !originalFetch.__patchstackGuarded) { |
| 26 | + const guarded = async (input, init) => { |
| 27 | + const url = typeof input === 'string' ? input : (input && input.url) || String(input); |
| 28 | + let host = null; |
| 29 | + try { |
| 30 | + host = new URL(url).hostname; |
| 31 | + } catch { |
| 32 | + host = null; |
| 33 | + } |
| 34 | + const method = (init && init.method) || (input && input.method) || 'GET'; |
| 35 | + if (block(url, host, method)) { |
| 36 | + throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`); |
| 37 | + } |
| 38 | + return originalFetch(input, init); |
| 39 | + }; |
| 40 | + guarded.__patchstackGuarded = true; |
| 41 | + globalThis.fetch = guarded; |
| 42 | + restores.push(() => { |
| 43 | + if (globalThis.fetch === guarded) globalThis.fetch = originalFetch; |
| 44 | + }); |
16 | 45 | } |
17 | 46 |
|
18 | | - const guarded = async (input, init) => { |
19 | | - const url = typeof input === 'string' ? input : (input && input.url) || String(input); |
20 | | - let host = null; |
| 47 | + // 2. node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws). |
| 48 | + for (const moduleName of ['node:http', 'node:https']) { |
21 | 49 | try { |
22 | | - host = new URL(url).hostname; |
| 50 | + const mod = await import(moduleName); |
| 51 | + const restore = patchHttpModule(mod.default ?? mod, block); |
| 52 | + if (restore) restores.push(restore); |
23 | 53 | } catch { |
24 | | - host = null; |
| 54 | + /* module not available on this runtime — skip */ |
25 | 55 | } |
26 | | - const method = (init && init.method) || (input && input.method) || 'GET'; |
| 56 | + } |
27 | 57 |
|
28 | | - if (shouldBlock(url, host, method)) { |
29 | | - onBlock?.({ url, host, method }); |
30 | | - throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`); |
| 58 | + return () => { |
| 59 | + for (const restore of restores) { |
| 60 | + try { |
| 61 | + restore(); |
| 62 | + } catch { |
| 63 | + /* ignore */ |
| 64 | + } |
31 | 65 | } |
32 | | - return original(input, init); |
33 | 66 | }; |
| 67 | +} |
| 68 | + |
| 69 | +// Wrap http(s).request/get so a blocked destination throws before the socket opens. |
| 70 | +function patchHttpModule(http, block) { |
| 71 | + if (!http || typeof http.request !== 'function' || http.__patchstackGuarded) return null; |
| 72 | + const originalRequest = http.request; |
| 73 | + const originalGet = http.get; |
34 | 74 |
|
35 | | - guarded.__patchstackGuarded = true; |
36 | | - globalThis.fetch = guarded; |
| 75 | + const wrap = (original) => |
| 76 | + function (...args) { |
| 77 | + const target = extractHttpTarget(args); |
| 78 | + if (target && block(target.url, target.host, target.method)) { |
| 79 | + throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`); |
| 80 | + } |
| 81 | + return original.apply(this, args); |
| 82 | + }; |
| 83 | + |
| 84 | + http.request = wrap(originalRequest); |
| 85 | + if (typeof originalGet === 'function') http.get = wrap(originalGet); |
| 86 | + http.__patchstackGuarded = true; |
37 | 87 |
|
38 | 88 | return () => { |
39 | | - if (globalThis.fetch === guarded) { |
40 | | - globalThis.fetch = original; |
41 | | - } |
| 89 | + http.request = originalRequest; |
| 90 | + if (typeof originalGet === 'function') http.get = originalGet; |
| 91 | + delete http.__patchstackGuarded; |
42 | 92 | }; |
43 | 93 | } |
| 94 | + |
| 95 | +// http.request accepts (url), (url, options), or (options) — with an optional trailing callback. |
| 96 | +function extractHttpTarget(args) { |
| 97 | + const first = args[0]; |
| 98 | + try { |
| 99 | + if (typeof first === 'string' || first instanceof URL) { |
| 100 | + const url = new URL(String(first)); |
| 101 | + const opts = args.find((a) => a && typeof a === 'object' && !(a instanceof URL)); |
| 102 | + return { url: url.href, host: url.hostname, method: (opts && opts.method) || 'GET' }; |
| 103 | + } |
| 104 | + if (first && typeof first === 'object') { |
| 105 | + const host = String(first.hostname || first.host || '').split(':')[0]; |
| 106 | + const protocol = first.protocol || 'http:'; |
| 107 | + const port = first.port ? `:${first.port}` : ''; |
| 108 | + const path = first.path || '/'; |
| 109 | + return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || 'GET' }; |
| 110 | + } |
| 111 | + } catch { |
| 112 | + /* fall through */ |
| 113 | + } |
| 114 | + return null; |
| 115 | +} |
0 commit comments