@lunora/browser 1.0.0-alpha.36 → 1.0.0-alpha.38
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/README.md
CHANGED
|
@@ -107,7 +107,9 @@ If you deliberately drive the browser at an internal service reachable through a
|
|
|
107
107
|
const browser = createBrowser({ binding: env.BROWSER, launch, allowPrivateTargets: true });
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
Only set `allowPrivateTargets` when every URL is trusted — it re-opens the SSRF surface.
|
|
110
|
+
Only set `allowPrivateTargets` when every URL is trusted — it re-opens the SSRF surface.
|
|
111
|
+
|
|
112
|
+
DNS rebinding is covered too: whenever `allowedHosts` is unset, the host is resolved over DoH and refused if it maps to a private address, before the browser launches and again on every redirect hop. Setting `allowedHosts` turns that re-check off, because an exact-host allowlist already closes rebinding and may deliberately name an internal host reachable over a Tunnel; pass `resolveDns: true` to force both.
|
|
111
113
|
|
|
112
114
|
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/packages/browser)**.
|
|
113
115
|
|
package/dist/index.d.mts
CHANGED
|
@@ -194,8 +194,13 @@ interface LunoraBrowserOptions {
|
|
|
194
194
|
* trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
|
|
195
195
|
* that fully closes DNS rebinding: a public hostname that resolves to a
|
|
196
196
|
* private/metadata IP can still be pinned out if it isn't on the list. Set it
|
|
197
|
-
* whenever you pass client-controlled URLs to the browser.
|
|
198
|
-
*
|
|
197
|
+
* whenever you pass client-controlled URLs to the browser.
|
|
198
|
+
*
|
|
199
|
+
* Leaving it unset (the default) is NOT unguarded: it turns
|
|
200
|
+
* {@link LunoraBrowserOptions.resolveDns} on, so every host is resolved over
|
|
201
|
+
* DoH and refused if it maps to a private address. Setting an allowlist turns
|
|
202
|
+
* that re-check off (the allowlist is the stronger guard, and may deliberately
|
|
203
|
+
* name an internal host); `resolveDns: true` forces both.
|
|
199
204
|
*/
|
|
200
205
|
allowedHosts?: string[];
|
|
201
206
|
/**
|
|
@@ -299,9 +304,12 @@ interface Browser {
|
|
|
299
304
|
* `fn` (e.g. for multi-page flows or APIs not surfaced here).
|
|
300
305
|
*
|
|
301
306
|
* The browser is **always closed** when `fn` resolves or throws — unless
|
|
302
|
-
* `keepAlive` is
|
|
303
|
-
* a later {@link Browser.connect} can
|
|
304
|
-
*
|
|
307
|
+
* `keepAlive` is a finite, positive number of seconds, which holds the
|
|
308
|
+
* session open for that long so a later {@link Browser.connect} can
|
|
309
|
+
* re-attach. `0`, a negative value and `NaN` all mean "do not keep alive"
|
|
310
|
+
* and take the always-close path (a held session is billed, so the
|
|
311
|
+
* ambiguous values fall to the safe side). Do not retain references to the
|
|
312
|
+
* browser past the callback either way.
|
|
305
313
|
*/
|
|
306
314
|
launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
307
315
|
keepAlive?: number;
|
package/dist/index.d.ts
CHANGED
|
@@ -194,8 +194,13 @@ interface LunoraBrowserOptions {
|
|
|
194
194
|
* trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
|
|
195
195
|
* that fully closes DNS rebinding: a public hostname that resolves to a
|
|
196
196
|
* private/metadata IP can still be pinned out if it isn't on the list. Set it
|
|
197
|
-
* whenever you pass client-controlled URLs to the browser.
|
|
198
|
-
*
|
|
197
|
+
* whenever you pass client-controlled URLs to the browser.
|
|
198
|
+
*
|
|
199
|
+
* Leaving it unset (the default) is NOT unguarded: it turns
|
|
200
|
+
* {@link LunoraBrowserOptions.resolveDns} on, so every host is resolved over
|
|
201
|
+
* DoH and refused if it maps to a private address. Setting an allowlist turns
|
|
202
|
+
* that re-check off (the allowlist is the stronger guard, and may deliberately
|
|
203
|
+
* name an internal host); `resolveDns: true` forces both.
|
|
199
204
|
*/
|
|
200
205
|
allowedHosts?: string[];
|
|
201
206
|
/**
|
|
@@ -299,9 +304,12 @@ interface Browser {
|
|
|
299
304
|
* `fn` (e.g. for multi-page flows or APIs not surfaced here).
|
|
300
305
|
*
|
|
301
306
|
* The browser is **always closed** when `fn` resolves or throws — unless
|
|
302
|
-
* `keepAlive` is
|
|
303
|
-
* a later {@link Browser.connect} can
|
|
304
|
-
*
|
|
307
|
+
* `keepAlive` is a finite, positive number of seconds, which holds the
|
|
308
|
+
* session open for that long so a later {@link Browser.connect} can
|
|
309
|
+
* re-attach. `0`, a negative value and `NaN` all mean "do not keep alive"
|
|
310
|
+
* and take the always-close path (a held session is billed, so the
|
|
311
|
+
* ambiguous values fall to the safe side). Do not retain references to the
|
|
312
|
+
* browser past the callback either way.
|
|
305
313
|
*/
|
|
306
314
|
launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
307
315
|
keepAlive?: number;
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createBrowser as o}from"./packem_shared/createBrowser-
|
|
1
|
+
import{createBrowser as o}from"./packem_shared/createBrowser-B2940bOf.mjs";export{o as createBrowser};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as u}from"@lunora/errors";const O=/^\d{1,3}$/u,x=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,V=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,C=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,F=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,X=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,Q=/^\[|\]$/gu,q=/\.$/u,v=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(n=>O.test(n)?Number(n):-1);if(!r.some(n=>n<0||n>255))return[r[0],r[1],r[2],r[3]]},y=([t,e])=>t===0||t===10||t===127||t===100&&e>=64&&e<=127||t===169&&e===254||t===172&&e>=16&&e<=31||t===192&&e===168||t>=224,D=(t,e)=>{const r=Number.parseInt(t??"",16),n=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(n)?!0:y([Math.floor(r/256),r%256,Math.floor(n/256),n%256])},$=t=>{const e=t.toLowerCase(),r=x.exec(e);if(r)return D(r[1],r[2]);const n=V.exec(e);if(n){const f=v(n[1]??"");return f===void 0||y(f)}const a=C.exec(e);if(a){const f=v(a[1]??"");return f===void 0||y(f)}const c=F.exec(e);return c?D(c[1],c[2]):X.test(e)||e.startsWith("2002:")||e.startsWith("2001:0:")?!0:e==="::"||e==="::1"||e.startsWith("fc")||e.startsWith("fd")||e.startsWith("fe8")||e.startsWith("fe9")||e.startsWith("fea")||e.startsWith("feb")},z=t=>t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal")||t.endsWith(".home.arpa"),h=t=>t.replaceAll(Q,"").replace(q,"").toLowerCase(),k=t=>{const e=h(t);if(e.includes(":"))return $(e);const r=v(e);return r===void 0?z(e):y(r)},G="https://cloudflare-dns.com/dns-query",E=1,N=28,j=2e3,Y=(t,e)=>{if(e===E){const r=v(t);return r===void 0||y(r)}return $(t.toLowerCase())},R=async(t,e,r)=>{try{const n=await fetch(`${G}?name=${encodeURIComponent(t)}&type=${String(e)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});return n.ok?(await n.json()).Answer??[]:void 0}catch{return}},J=async(t,e=j)=>{const r=h(t);if(r.includes(":")||v(r)!==void 0)return{kind:"unknown"};const[n,a]=await Promise.all([R(r,E,e),R(r,N,e)]);if(n===void 0&&a===void 0)return{kind:"unknown"};for(const c of[...n??[],...a??[]])if((c.type===E||c.type===N)&&Y(c.data,c.type))return{address:c.data,kind:"private"};return{kind:"public"}},A=3e4,Z=12e4,K=3840,tt=4320,L=5e3,M=async(t,e=L)=>{const r=h(new URL(t).hostname),n=await J(r,e);if(n.kind==="private")throw new u("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${n.address}); refusing to navigate (DNS-rebinding guard)`)},H=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new u("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let n;try{n=new URL(t)}catch{throw new u("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new u("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${n.protocol}")`);if(n.username!==""||n.password!=="")throw new u("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const a=h(n.hostname);if(!r.some(c=>h(c)===a))throw new u("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&k(n.hostname))throw new u("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return n.toString()},S=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,et=t=>({height:S(t.height,tt),width:S(t.width,K)}),rt=(t,e)=>{const r=t??e??A,n=Number.isFinite(r)?r:A;return Math.min(Math.max(1,Math.floor(n)),Z)},nt=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((n,a)=>{r=setTimeout(()=>{a(new u("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},B=async t=>{try{await t.close()}catch{}},dt=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new u("INTERNAL",'@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").');return t.launch},r=(o,s)=>{if(!o)throw new u("INTERNAL",`@lunora/browser: \`${s}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${s} }).`);return o},n=async(o,s)=>{const i=s!==void 0&&Number.isFinite(s)&&s>0?s:void 0,l=await e()(t.binding,i===void 0?void 0:{keep_alive:i*1e3});if(i!==void 0)return await o(l);try{return await o(l)}finally{await B(l)}},a=async(o,s,i,l)=>{const g=t.allowPrivateTargets??!1,I=H(o,g,t.allowedHosts),b=rt(s.timeoutMs,t.timeoutMs),P=t.resolveDns??(t.allowedHosts?.length??0)===0,_=Math.min(b,L);!g&&P&&await M(I,_);const W=async m=>{H(m,g,t.allowedHosts),!g&&P&&await M(m,_)},U=m=>{let p;try{p=new URL(m)}catch{return!1}if(p.protocol!=="http:"&&p.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const d=h(p.hostname);if(!t.allowedHosts.some(w=>h(w)===d))return!0}return k(p.hostname)};return n(async m=>{const d=await(await m.newContext()).newPage();return d.route&&(!g||(t.allowedHosts?.length??0)>0)&&await d.route("**/*",async w=>{const T=w.request();if(!(T.isNavigationRequest?.()??!0)){if(U(T.url())){await w.abort("blockedbyclient");return}await w.continue();return}try{await W(T.url())}catch{await w.abort("blockedbyclient");return}await w.continue()}),l&&d.setViewportSize&&await d.setViewportSize(et(l)),nt(async()=>(await d.goto(I,{timeout:b,waitUntil:s.waitUntil??"load"}),i(d)),b)})};return{connect:async(o,s,i={})=>{const l=await r(t.connect,"connect")(t.binding,o);if(i.close!==!0)return await s(l);try{return await s(l)}finally{await B(l)}},content:async(o,s={})=>a(o,s,async i=>i.content()),launch:async(o,s={})=>n(o,s.keepAlive),pdf:async(o,s={})=>a(o,s,async i=>i.pdf({format:s.format,printBackground:s.printBackground??!1}),s.viewport),scrape:async(o,s,i={})=>a(o,i,async l=>l.evaluate(s)),screenshot:async(o,s={})=>a(o,s,async i=>i.screenshot({fullPage:s.fullPage??!1,type:s.type??"png"}),s.viewport),sessions:async()=>await r(t.sessions,"sessions")(t.binding)}};export{dt as createBrowser};
|
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.38",
|
|
4
4
|
"description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser-rendering",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.30"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@cloudflare/playwright": ">=1.0.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{LunoraError as l}from"@lunora/errors";const O=/^\d{1,3}$/u,x=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,V=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,C=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,F=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,q=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,X=/^\[|\]$/gu,Q=/\.$/u,v=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(n=>O.test(n)?Number(n):-1);if(!r.some(n=>n<0||n>255))return[r[0],r[1],r[2],r[3]]},y=([t,e])=>t===0||t===10||t===127||t===100&&e>=64&&e<=127||t===169&&e===254||t===172&&e>=16&&e<=31||t===192&&e===168||t>=224,D=(t,e)=>{const r=Number.parseInt(t??"",16),n=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(n)?!0:y([Math.floor(r/256),r%256,Math.floor(n/256),n%256])},$=t=>{const e=t.toLowerCase(),r=x.exec(e);if(r)return D(r[1],r[2]);const n=V.exec(e);if(n){const f=v(n[1]??"");return f===void 0||y(f)}const a=C.exec(e);if(a){const f=v(a[1]??"");return f===void 0||y(f)}const c=F.exec(e);return c?D(c[1],c[2]):q.test(e)||e.startsWith("2002:")||e.startsWith("2001:0:")?!0:e==="::"||e==="::1"||e.startsWith("fc")||e.startsWith("fd")||e.startsWith("fe8")||e.startsWith("fe9")||e.startsWith("fea")||e.startsWith("feb")},z=t=>t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal")||t.endsWith(".home.arpa"),h=t=>t.replaceAll(X,"").replace(Q,"").toLowerCase(),L=t=>{const e=h(t);if(e.includes(":"))return $(e);const r=v(e);return r===void 0?z(e):y(r)},G="https://cloudflare-dns.com/dns-query",E=1,R=28,j=2e3,Y=(t,e)=>{if(e===E){const r=v(t);return r===void 0||y(r)}return $(t.toLowerCase())},N=async(t,e,r)=>{try{const n=await fetch(`${G}?name=${encodeURIComponent(t)}&type=${String(e)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});return n.ok?(await n.json()).Answer??[]:void 0}catch{return}},K=async(t,e=j)=>{const r=h(t);if(r.includes(":")||v(r)!==void 0)return{kind:"unknown"};const[n,a]=await Promise.all([N(r,E,e),N(r,R,e)]);if(n===void 0&&a===void 0)return{kind:"unknown"};for(const c of[...n??[],...a??[]])if((c.type===E||c.type===R)&&Y(c.data,c.type))return{address:c.data,kind:"private"};return{kind:"public"}},A=3e4,J=12e4,Z=3840,tt=4320,W=5e3,M=async(t,e=W)=>{const r=h(new URL(t).hostname),n=await K(r,e);if(n.kind==="private")throw new l("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${n.address}); refusing to navigate (DNS-rebinding guard)`)},H=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new l("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let n;try{n=new URL(t)}catch{throw new l("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new l("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${n.protocol}")`);if(n.username!==""||n.password!=="")throw new l("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const a=h(n.hostname);if(!r.some(c=>h(c)===a))throw new l("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&L(n.hostname))throw new l("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return n.toString()},S=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,et=t=>({height:S(t.height,tt),width:S(t.width,Z)}),rt=(t,e)=>{const r=t??e??A,n=Number.isFinite(r)?r:A;return Math.min(Math.max(1,Math.floor(n)),J)},nt=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((n,a)=>{r=setTimeout(()=>{a(new l("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},B=async t=>{try{await t.close()}catch{}},dt=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new l("INTERNAL",'@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").');return t.launch},r=(o,s)=>{if(!o)throw new l("INTERNAL",`@lunora/browser: \`${s}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${s} }).`);return o},n=async(o,s)=>{const i=await e()(t.binding,s===void 0?void 0:{keep_alive:s*1e3});if(s!==void 0)return await o(i);try{return await o(i)}finally{await B(i)}},a=async(o,s,i,u)=>{const g=t.allowPrivateTargets??!1,I=H(o,g,t.allowedHosts),b=rt(s.timeoutMs,t.timeoutMs),P=t.resolveDns??(t.allowedHosts?.length??0)===0,_=Math.min(b,W);!g&&P&&await M(I,_);const k=async m=>{H(m,g,t.allowedHosts),!g&&P&&await M(m,_)},U=m=>{let p;try{p=new URL(m)}catch{return!1}if(p.protocol!=="http:"&&p.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const d=h(p.hostname);if(!t.allowedHosts.some(w=>h(w)===d))return!0}return L(p.hostname)};return n(async m=>{const d=await(await m.newContext()).newPage();return d.route&&(!g||(t.allowedHosts?.length??0)>0)&&await d.route("**/*",async w=>{const T=w.request();if(!(T.isNavigationRequest?.()??!0)){if(U(T.url())){await w.abort("blockedbyclient");return}await w.continue();return}try{await k(T.url())}catch{await w.abort("blockedbyclient");return}await w.continue()}),u&&d.setViewportSize&&await d.setViewportSize(et(u)),nt(async()=>(await d.goto(I,{timeout:b,waitUntil:s.waitUntil??"load"}),i(d)),b)})};return{connect:async(o,s,i={})=>{const u=await r(t.connect,"connect")(t.binding,o);if(i.close!==!0)return await s(u);try{return await s(u)}finally{await B(u)}},content:async(o,s={})=>a(o,s,async i=>i.content()),launch:async(o,s={})=>n(o,s.keepAlive),pdf:async(o,s={})=>a(o,s,async i=>i.pdf({format:s.format,printBackground:s.printBackground??!1}),s.viewport),scrape:async(o,s,i={})=>a(o,i,async u=>u.evaluate(s)),screenshot:async(o,s={})=>a(o,s,async i=>i.screenshot({fullPage:s.fullPage??!1,type:s.type??"png"}),s.viewport),sessions:async()=>await r(t.sessions,"sessions")(t.binding)}};export{dt as createBrowser};
|