@lunora/browser 1.0.0-alpha.2 → 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -15,6 +15,23 @@ interface BrowserBindingLike {
15
15
  readonly fetch?: (...args: never[]) => unknown;
16
16
  }
17
17
  /**
18
+ * Minimal projection of a Playwright `Route` (the argument the `page.route`
19
+ * handler receives). Only the members the SSRF redirect guard drives are
20
+ * declared: inspect the intercepted request's URL / navigation-ness, then either
21
+ * let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
22
+ */
23
+ interface RouteLike {
24
+ /** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
25
+ abort: (errorCode?: string) => Promise<void>;
26
+ /** Allow the intercepted request to proceed. */
27
+ continue: () => Promise<void>;
28
+ /** The intercepted request: its URL and (when available) whether it is a top-level navigation. */
29
+ request: () => {
30
+ isNavigationRequest?: () => boolean;
31
+ url: () => string;
32
+ };
33
+ }
34
+ /**
18
35
  * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
19
36
  * Declared structurally so a test can inject a plain stub instead of a real
20
37
  * headless page (which needs workerd + the Browser Rendering binding).
@@ -31,6 +48,13 @@ interface PageLike {
31
48
  }) => Promise<unknown>;
32
49
  /** Render the page to a PDF buffer. */
33
50
  pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
51
+ /**
52
+ * Register a request interceptor (Playwright `page.route`). Optional: a fake
53
+ * or older page double without it still works — the SSRF redirect guard only
54
+ * activates when interception is available, and the initial-URL guard applies
55
+ * regardless. `pattern` follows Playwright's glob/URL matcher.
56
+ */
57
+ route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
34
58
  /** Render the page to a PNG/JPEG buffer. */
35
59
  screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
60
  /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
@@ -109,6 +133,16 @@ interface PdfOptions extends NavigateOptions {
109
133
  };
110
134
  }
111
135
  interface LunoraBrowserOptions {
136
+ /**
137
+ * Strict host allowlist. When set (non-empty), a navigation URL is refused
138
+ * unless its hostname exactly matches one of these entries (case-insensitive,
139
+ * trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
140
+ * that fully closes DNS rebinding: a public hostname that resolves to a
141
+ * private/metadata IP can still be pinned out if it isn't on the list. Set it
142
+ * whenever you pass client-controlled URLs to the browser. Leave it unset (the
143
+ * default) to keep the previous behavior (only the string-based SSRF guard).
144
+ */
145
+ allowedHosts?: string[];
112
146
  /**
113
147
  * Opt out of the SSRF guard that, by default, refuses to navigate to a
114
148
  * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
@@ -131,6 +165,18 @@ interface LunoraBrowserOptions {
131
165
  */
132
166
  launch?: BrowserLaunchLike;
133
167
  /**
168
+ * Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
169
+ * is `false`), the factory resolves the URL's hostname over Cloudflare DoH
170
+ * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
171
+ * resolved A/AAAA record is a private/internal address — closing the gap
172
+ * where a public hostname resolves to a private IP after the string guard
173
+ * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
174
+ * (the browser re-resolves independently). If the DoH lookup itself fails, it
175
+ * falls back to the string guard rather than allowing a resolved private IP.
176
+ * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
177
+ */
178
+ resolveDns?: boolean;
179
+ /**
134
180
  * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
135
181
  * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
136
182
  */
package/dist/index.d.ts CHANGED
@@ -15,6 +15,23 @@ interface BrowserBindingLike {
15
15
  readonly fetch?: (...args: never[]) => unknown;
16
16
  }
17
17
  /**
18
+ * Minimal projection of a Playwright `Route` (the argument the `page.route`
19
+ * handler receives). Only the members the SSRF redirect guard drives are
20
+ * declared: inspect the intercepted request's URL / navigation-ness, then either
21
+ * let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
22
+ */
23
+ interface RouteLike {
24
+ /** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
25
+ abort: (errorCode?: string) => Promise<void>;
26
+ /** Allow the intercepted request to proceed. */
27
+ continue: () => Promise<void>;
28
+ /** The intercepted request: its URL and (when available) whether it is a top-level navigation. */
29
+ request: () => {
30
+ isNavigationRequest?: () => boolean;
31
+ url: () => string;
32
+ };
33
+ }
34
+ /**
18
35
  * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
19
36
  * Declared structurally so a test can inject a plain stub instead of a real
20
37
  * headless page (which needs workerd + the Browser Rendering binding).
@@ -31,6 +48,13 @@ interface PageLike {
31
48
  }) => Promise<unknown>;
32
49
  /** Render the page to a PDF buffer. */
33
50
  pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
51
+ /**
52
+ * Register a request interceptor (Playwright `page.route`). Optional: a fake
53
+ * or older page double without it still works — the SSRF redirect guard only
54
+ * activates when interception is available, and the initial-URL guard applies
55
+ * regardless. `pattern` follows Playwright's glob/URL matcher.
56
+ */
57
+ route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
34
58
  /** Render the page to a PNG/JPEG buffer. */
35
59
  screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
60
  /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
@@ -109,6 +133,16 @@ interface PdfOptions extends NavigateOptions {
109
133
  };
110
134
  }
111
135
  interface LunoraBrowserOptions {
136
+ /**
137
+ * Strict host allowlist. When set (non-empty), a navigation URL is refused
138
+ * unless its hostname exactly matches one of these entries (case-insensitive,
139
+ * trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
140
+ * that fully closes DNS rebinding: a public hostname that resolves to a
141
+ * private/metadata IP can still be pinned out if it isn't on the list. Set it
142
+ * whenever you pass client-controlled URLs to the browser. Leave it unset (the
143
+ * default) to keep the previous behavior (only the string-based SSRF guard).
144
+ */
145
+ allowedHosts?: string[];
112
146
  /**
113
147
  * Opt out of the SSRF guard that, by default, refuses to navigate to a
114
148
  * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
@@ -131,6 +165,18 @@ interface LunoraBrowserOptions {
131
165
  */
132
166
  launch?: BrowserLaunchLike;
133
167
  /**
168
+ * Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
169
+ * is `false`), the factory resolves the URL's hostname over Cloudflare DoH
170
+ * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
171
+ * resolved A/AAAA record is a private/internal address — closing the gap
172
+ * where a public hostname resolves to a private IP after the string guard
173
+ * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
174
+ * (the browser re-resolves independently). If the DoH lookup itself fails, it
175
+ * falls back to the string guard rather than allowing a resolved private IP.
176
+ * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
177
+ */
178
+ resolveDns?: boolean;
179
+ /**
134
180
  * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
135
181
  * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
136
182
  */
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { createBrowser } from './packem_shared/createBrowser-6RQ-H1k9.mjs';
1
+ export { createBrowser } from './packem_shared/createBrowser-BaV8oQ-4.mjs';
@@ -1,3 +1,5 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const DEFAULT_TIMEOUT_MS = 3e4;
2
4
  const MAX_TIMEOUT_MS = 12e4;
3
5
  const MAX_VIEWPORT_WIDTH = 3840;
@@ -9,6 +11,7 @@ const IPV6_COMPATIBLE_DOTTED = /^::(\d{1,3}(?:\.\d{1,3}){3})$/;
9
11
  const IPV6_COMPATIBLE_HEX = /^::([\da-f]{1,4}):([\da-f]{1,4})$/;
10
12
  const IPV6_NAT64_HEX = /^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/;
11
13
  const IPV6_BRACKETS = /^\[|\]$/g;
14
+ const TRAILING_DOT = /\.$/;
12
15
  const parseIpv4 = (host) => {
13
16
  const parts = host.split(".");
14
17
  if (parts.length !== 4) {
@@ -67,33 +70,88 @@ const isPrivateIpv6 = (host) => {
67
70
  ip.startsWith("fe8") || // fe80::/10 link-local
68
71
  ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb");
69
72
  };
73
+ const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
74
+ const DOH_TIMEOUT_MS = 5e3;
75
+ const DNS_TYPE_A = 1;
76
+ const DNS_TYPE_AAAA = 28;
77
+ const normalizeHost = (host) => host.replaceAll(IPV6_BRACKETS, "").replace(TRAILING_DOT, "").toLowerCase();
78
+ const isPrivateResolvedIp = (data, type) => {
79
+ if (type === DNS_TYPE_A) {
80
+ const v4 = parseIpv4(data);
81
+ return v4 === void 0 || isPrivateIpv4(v4);
82
+ }
83
+ return isPrivateIpv6(data.toLowerCase());
84
+ };
85
+ const dohLookup = async (hostname, type, timeoutMs = DOH_TIMEOUT_MS) => {
86
+ try {
87
+ const response = await fetch(`${DOH_ENDPOINT}?name=${encodeURIComponent(hostname)}&type=${String(type)}`, {
88
+ headers: { accept: "application/dns-json" },
89
+ // Bound the lookup so a stalled resolver can't hang the worker; an
90
+ // abort surfaces as a rejection caught below → `undefined` → the
91
+ // caller falls back to the (already-passed) string guard.
92
+ signal: AbortSignal.timeout(timeoutMs)
93
+ });
94
+ if (!response.ok) {
95
+ return void 0;
96
+ }
97
+ const body = await response.json();
98
+ return body.Answer ?? [];
99
+ } catch {
100
+ return void 0;
101
+ }
102
+ };
103
+ const assertResolvedHostIsPublic = async (target, timeoutMs = DOH_TIMEOUT_MS) => {
104
+ const host = normalizeHost(new URL(target).hostname);
105
+ if (host.includes(":") || parseIpv4(host) !== void 0) {
106
+ return;
107
+ }
108
+ const [aRecords, aaaaRecords] = await Promise.all([dohLookup(host, DNS_TYPE_A, timeoutMs), dohLookup(host, DNS_TYPE_AAAA, timeoutMs)]);
109
+ if (aRecords === void 0 && aaaaRecords === void 0) {
110
+ return;
111
+ }
112
+ for (const answer of [...aRecords ?? [], ...aaaaRecords ?? []]) {
113
+ if ((answer.type === DNS_TYPE_A || answer.type === DNS_TYPE_AAAA) && isPrivateResolvedIp(answer.data, answer.type)) {
114
+ throw new LunoraError(
115
+ "FORBIDDEN",
116
+ `@lunora/browser: url host "${host}" resolves to a private/internal address (${answer.data}); refusing to navigate (DNS-rebinding guard)`
117
+ );
118
+ }
119
+ }
120
+ };
70
121
  const isPrivateHostname = (host) => host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".home.arpa");
71
122
  const isPrivateTarget = (parsed) => {
72
- const host = parsed.hostname.replaceAll(IPV6_BRACKETS, "");
123
+ const host = parsed.hostname.replaceAll(IPV6_BRACKETS, "").replace(TRAILING_DOT, "");
73
124
  if (host.includes(":")) {
74
125
  return isPrivateIpv6(host);
75
126
  }
76
127
  const v4 = parseIpv4(host);
77
128
  return v4 === void 0 ? isPrivateHostname(host.toLowerCase()) : isPrivateIpv4(v4);
78
129
  };
79
- const validateUrl = (url, allowPrivateTargets) => {
130
+ const validateUrl = (url, allowPrivateTargets, allowedHosts) => {
80
131
  if (typeof url !== "string" || url.length === 0) {
81
- throw new Error("@lunora/browser: url must be a non-empty string");
132
+ throw new TypeError("@lunora/browser: url must be a non-empty string");
82
133
  }
83
134
  let parsed;
84
135
  try {
85
136
  parsed = new URL(url);
86
137
  } catch {
87
- throw new Error(`@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
138
+ throw new TypeError(`@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
88
139
  }
89
140
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
90
- throw new Error(`@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
141
+ throw new TypeError(`@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
91
142
  }
92
143
  if (parsed.username !== "" || parsed.password !== "") {
93
- throw new Error("@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
144
+ throw new LunoraError("INTERNAL", "@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
145
+ }
146
+ if (allowedHosts && allowedHosts.length > 0) {
147
+ const host = normalizeHost(parsed.hostname);
148
+ if (!allowedHosts.some((entry) => normalizeHost(entry) === host)) {
149
+ throw new LunoraError("FORBIDDEN", `@lunora/browser: url host "${parsed.hostname}" is not in the configured allowedHosts allowlist`);
150
+ }
94
151
  }
95
152
  if (!allowPrivateTargets && isPrivateTarget(parsed)) {
96
- throw new Error(
153
+ throw new LunoraError(
154
+ "INTERNAL",
97
155
  `@lunora/browser: url host "${parsed.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`
98
156
  );
99
157
  }
@@ -118,11 +176,12 @@ const resolveTimeout = (callTimeout, factoryTimeout) => {
118
176
  };
119
177
  const createBrowser = (options) => {
120
178
  if (!options.binding) {
121
- throw new Error("@lunora/browser: `binding` is required (env.BROWSER)");
179
+ throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");
122
180
  }
123
181
  const getLaunch = () => {
124
182
  if (!options.launch) {
125
- throw new Error(
183
+ throw new LunoraError(
184
+ "INTERNAL",
126
185
  '@lunora/browser: `launch` is not available — install the `@cloudflare/playwright` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, launch }) (import { launch } from "@cloudflare/playwright").'
127
186
  );
128
187
  }
@@ -140,11 +199,40 @@ const createBrowser = (options) => {
140
199
  }
141
200
  };
142
201
  const withPage = async (url, navigate, use, viewport) => {
143
- const target = validateUrl(url, options.allowPrivateTargets ?? false);
202
+ const allowPrivateTargets = options.allowPrivateTargets ?? false;
203
+ const target = validateUrl(url, allowPrivateTargets, options.allowedHosts);
144
204
  const timeout = resolveTimeout(navigate.timeoutMs, options.timeoutMs);
205
+ const resolveDns = options.resolveDns ?? false;
206
+ const dohTimeout = Math.min(timeout, DOH_TIMEOUT_MS);
207
+ if (!allowPrivateTargets && resolveDns) {
208
+ await assertResolvedHostIsPublic(target, dohTimeout);
209
+ }
210
+ const assertNavigationAllowed = async (requestUrl) => {
211
+ validateUrl(requestUrl, allowPrivateTargets, options.allowedHosts);
212
+ if (resolveDns) {
213
+ await assertResolvedHostIsPublic(requestUrl, dohTimeout);
214
+ }
215
+ };
145
216
  return withBrowser(async (browser) => {
146
217
  const context = await browser.newContext();
147
218
  const page = await context.newPage();
219
+ if (!allowPrivateTargets && page.route) {
220
+ await page.route("**/*", async (route) => {
221
+ const request = route.request();
222
+ const isNavigation = request.isNavigationRequest?.() ?? true;
223
+ if (!isNavigation) {
224
+ await route.continue();
225
+ return;
226
+ }
227
+ try {
228
+ await assertNavigationAllowed(request.url());
229
+ } catch {
230
+ await route.abort("blockedbyclient");
231
+ return;
232
+ }
233
+ await route.continue();
234
+ });
235
+ }
148
236
  if (viewport && page.setViewportSize) {
149
237
  await page.setViewportSize(clampViewport(viewport));
150
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.4",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -45,6 +45,9 @@
45
45
  "publishConfig": {
46
46
  "access": "public"
47
47
  },
48
+ "dependencies": {
49
+ "@lunora/errors": "1.0.0-alpha.1"
50
+ },
48
51
  "peerDependencies": {
49
52
  "@cloudflare/playwright": ">=1.0.0"
50
53
  },