@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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
},
|