@lunora/browser 1.0.0-alpha.1 → 1.0.0-alpha.10

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.
@@ -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 LunoraError("BAD_REQUEST", "@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 LunoraError("BAD_REQUEST", `@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 LunoraError("BAD_REQUEST", `@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("BAD_REQUEST", "@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
+ "FORBIDDEN",
97
155
  `@lunora/browser: url host "${parsed.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`
98
156
  );
99
157
  }
@@ -116,13 +174,35 @@ const resolveTimeout = (callTimeout, factoryTimeout) => {
116
174
  const safe = Number.isFinite(requested) ? requested : DEFAULT_TIMEOUT_MS;
117
175
  return Math.min(Math.max(1, Math.floor(safe)), MAX_TIMEOUT_MS);
118
176
  };
177
+ const withDeadline = async (operation, timeoutMs) => {
178
+ let timer;
179
+ try {
180
+ return await Promise.race([
181
+ operation(),
182
+ new Promise((_resolve, reject) => {
183
+ timer = setTimeout(() => {
184
+ reject(
185
+ new LunoraError("BROWSER_TIMEOUT", `@lunora/browser: navigation + operation exceeded the ${String(timeoutMs)}ms timeout budget`, {
186
+ status: 504
187
+ })
188
+ );
189
+ }, timeoutMs);
190
+ })
191
+ ]);
192
+ } finally {
193
+ if (timer !== void 0) {
194
+ clearTimeout(timer);
195
+ }
196
+ }
197
+ };
119
198
  const createBrowser = (options) => {
120
199
  if (!options.binding) {
121
- throw new Error("@lunora/browser: `binding` is required (env.BROWSER)");
200
+ throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");
122
201
  }
123
202
  const getLaunch = () => {
124
203
  if (!options.launch) {
125
- throw new Error(
204
+ throw new LunoraError(
205
+ "INTERNAL",
126
206
  '@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
207
  );
128
208
  }
@@ -140,16 +220,69 @@ const createBrowser = (options) => {
140
220
  }
141
221
  };
142
222
  const withPage = async (url, navigate, use, viewport) => {
143
- const target = validateUrl(url, options.allowPrivateTargets ?? false);
223
+ const allowPrivateTargets = options.allowPrivateTargets ?? false;
224
+ const target = validateUrl(url, allowPrivateTargets, options.allowedHosts);
144
225
  const timeout = resolveTimeout(navigate.timeoutMs, options.timeoutMs);
226
+ const resolveDns = options.resolveDns ?? false;
227
+ const dohTimeout = Math.min(timeout, DOH_TIMEOUT_MS);
228
+ if (!allowPrivateTargets && resolveDns) {
229
+ await assertResolvedHostIsPublic(target, dohTimeout);
230
+ }
231
+ const assertNavigationAllowed = async (requestUrl) => {
232
+ validateUrl(requestUrl, allowPrivateTargets, options.allowedHosts);
233
+ if (!allowPrivateTargets && resolveDns) {
234
+ await assertResolvedHostIsPublic(requestUrl, dohTimeout);
235
+ }
236
+ };
237
+ const isBlockedSubresource = (rawUrl) => {
238
+ let parsed;
239
+ try {
240
+ parsed = new URL(rawUrl);
241
+ } catch {
242
+ return false;
243
+ }
244
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
245
+ return false;
246
+ }
247
+ if (options.allowedHosts && options.allowedHosts.length > 0) {
248
+ const host = normalizeHost(parsed.hostname);
249
+ if (!options.allowedHosts.some((entry) => normalizeHost(entry) === host)) {
250
+ return true;
251
+ }
252
+ }
253
+ return isPrivateTarget(parsed);
254
+ };
145
255
  return withBrowser(async (browser) => {
146
256
  const context = await browser.newContext();
147
257
  const page = await context.newPage();
258
+ if (page.route && (!allowPrivateTargets || (options.allowedHosts?.length ?? 0) > 0)) {
259
+ await page.route("**/*", async (route) => {
260
+ const request = route.request();
261
+ const isNavigation = request.isNavigationRequest?.() ?? true;
262
+ if (!isNavigation) {
263
+ if (isBlockedSubresource(request.url())) {
264
+ await route.abort("blockedbyclient");
265
+ return;
266
+ }
267
+ await route.continue();
268
+ return;
269
+ }
270
+ try {
271
+ await assertNavigationAllowed(request.url());
272
+ } catch {
273
+ await route.abort("blockedbyclient");
274
+ return;
275
+ }
276
+ await route.continue();
277
+ });
278
+ }
148
279
  if (viewport && page.setViewportSize) {
149
280
  await page.setViewportSize(clampViewport(viewport));
150
281
  }
151
- await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
152
- return use(page);
282
+ return withDeadline(async () => {
283
+ await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
284
+ return use(page);
285
+ }, timeout);
153
286
  });
154
287
  };
155
288
  const screenshot = async (url, screenshotOptions = {}) => withPage(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.10",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/browser"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "__assets__",
30
30
  "README.md",
31
31
  "LICENSE.md"
@@ -45,6 +45,9 @@
45
45
  "publishConfig": {
46
46
  "access": "public"
47
47
  },
48
+ "dependencies": {
49
+ "@lunora/errors": "1.0.0-alpha.7"
50
+ },
48
51
  "peerDependencies": {
49
52
  "@cloudflare/playwright": ">=1.0.0"
50
53
  },