@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. The guard does not resolve DNS, so a public hostname that resolves to a private address (DNS rebinding) is out of scope; keep caller-supplied URLs trusted regardless.
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. Leave it unset (the
198
- * default) to keep the previous behavior (only the string-based SSRF guard).
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 set, which holds the session open for that many seconds so
303
- * a later {@link Browser.connect} can re-attach. Do not retain references to
304
- * the browser past the callback either way.
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. Leave it unset (the
198
- * default) to keep the previous behavior (only the string-based SSRF guard).
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 set, which holds the session open for that many seconds so
303
- * a later {@link Browser.connect} can re-attach. Do not retain references to
304
- * the browser past the callback either way.
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-BicVf2xu.mjs";export{o as 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.36",
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.29"
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};