@stacksjs/security 0.75.187 → 0.75.188

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/crypt.d.ts CHANGED
@@ -1,12 +1,6 @@
1
1
  import { encrypt as cryptoEncrypt } from 'ts-security-crypto';
2
2
  declare function encrypt(message: string, customPassphrase?: string): Promise<string>;
3
3
  declare function decrypt(encrypted: string, customPassphrase?: string): Promise<string>;
4
- /**
5
- * For tests + observability: reset the per-process derived-key cache.
6
- * Production code never needs to call this — the cache is bounded
7
- * (`DERIVED_KEY_CACHE_MAX = 64`) and entries evict in FIFO order.
8
- */
9
- export declare function _resetCryptCacheForTests(): void;
10
4
  // Re-export pre-existing helpers from ts-security-crypto so callers
11
5
  // that imported them from `@stacksjs/security` keep working.
12
6
  export { decrypt, encrypt };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- var V=import.meta.require;import{Buffer as b}from"buffer";import{config as S}from"@stacksjs/config";import{decrypt as P,encrypt as wt}from"ts-security-crypto";function k(r){return r.startsWith("base64:")?r.slice(7):r}var U=1,B=600000,W=32,f=16,l=12,D=64,g=new Map;function I(r,t,e){return`${e}:${b.from(t).toString("base64")}:${r}`}async function C(r,t,e){let n=I(r,t,e),s=g.get(n);if(s)return s.hits++,g.delete(n),g.set(n,s),s.key;let o=await crypto.subtle.importKey("raw",new TextEncoder().encode(r),{name:"PBKDF2"},!1,["deriveKey"]),a=await crypto.subtle.deriveKey({name:"PBKDF2",salt:new Uint8Array(t),iterations:e,hash:"SHA-256"},o,{name:"AES-GCM",length:W*8},!1,["encrypt","decrypt"]);if(g.set(n,{key:a,hits:1}),g.size>D){let i=g.keys().next().value;if(i!==void 0)g.delete(i)}return a}async function G(r,t){let e=crypto.getRandomValues(new Uint8Array(f)),n=crypto.getRandomValues(new Uint8Array(l)),s=await C(t,e,B),o=await crypto.subtle.encrypt({name:"AES-GCM",iv:n},s,new TextEncoder().encode(r)),a=new Uint8Array(o),i=new Uint8Array(1+f+l+a.byteLength);return i[0]=U,i.set(e,1),i.set(n,1+f),i.set(a,1+f+l),b.from(i).toString("base64")}async function q(r,t){let e=b.from(r,"base64");if(e.length<1+f+l+16)return null;if(e[0]!==U)return null;let n=new Uint8Array(e.subarray(1,1+f)),s=new Uint8Array(e.subarray(1+f,1+f+l)),o=new Uint8Array(e.subarray(1+f+l)),a=await C(t,n,B);try{let i=await crypto.subtle.decrypt({name:"AES-GCM",iv:s},a,o);return new TextDecoder().decode(i)}catch{return null}}async function bt(r,t){if(!r&&r!=="")throw Error("encrypt() requires a string message");let e=t||S.app.key;if(!e)throw Error("APP_KEY is not defined");return await G(r,k(e))}async function xt(r,t){if(!r)throw Error("decrypt() requires a non-empty encrypted string");let e=t||S.app.key;if(!e)throw Error("APP_KEY is not defined");let n=k(e),s=await q(r,n);if(s!==null)return s;try{return await P(r,n)}catch(o){if(n!==e)try{return await P(r,e)}catch{}throw o}}function $t(){g.clear()}import{timingSafeEqual as K}from"crypto";import{base64Encode as Pt,hashPassword as F,verifyPassword as R}from"ts-security-crypto";var h=null;function d(){if(h)return h;try{let{hashing:r}=V("@stacksjs/config");return h=r||{},h}catch{return console.warn("[Security] Failed to load hashing config, using defaults"),h={driver:"bcrypt",bcrypt:{rounds:12},argon2:{memory:65536,time:2}},h}}function N(r){if(!r||typeof r!=="string")return"unknown";if(r.startsWith("$2a$")||r.startsWith("$2b$")||r.startsWith("$2y$"))return"bcrypt";if(r.startsWith("$argon2id$"))return"argon2id";if(r.startsWith("$argon2i$"))return"argon2i";if(r.startsWith("$argon2d$"))return"argon2d";return"unknown"}function Y(r){let t=N(r),e={};if(t==="bcrypt"){let n=r.match(/^\$2[aby]\$(\d{2})\$/);if(n&&n[1]!==void 0)e.rounds=Number.parseInt(n[1],10)}else if(t==="argon2id"||t==="argon2i"||t==="argon2d"){let n=r.match(/v=(\d+)/),s=r.match(/m=(\d+)/),o=r.match(/t=(\d+)/),a=r.match(/p=(\d+)/);if(n&&n[1]!==void 0)e.version=Number.parseInt(n[1],10);if(s&&s[1]!==void 0)e.memory=Number.parseInt(s[1],10);if(o&&o[1]!==void 0)e.rounds=Number.parseInt(o[1],10);if(a&&a[1]!==void 0)e.parallelism=Number.parseInt(a[1],10)}return{algorithm:t,options:e}}function St(r,t){let e=Y(r),n=d(),s=t?.algorithm||n.driver||"bcrypt",o=(u)=>{if(u==="argon2")return"argon2id";return u},a=o(e.algorithm),i=o(s);if(a!==i)return!0;if(a==="bcrypt"){let u=t?.rounds||n.bcrypt?.rounds||12;if(e.options.rounds!==u)return!0}if(a.startsWith("argon2")){let u=t?.memory||n.argon2?.memory||65536,m=t?.time||n.argon2?.time||2;if(e.options.memory!==u||e.options.rounds!==m)return!0}return!1}async function kt(r,t){let e=d(),n=t?.algorithm||e.driver||"bcrypt";if(n==="bcrypt")return await z(r,t?.rounds);if(n==="argon2"||n==="argon2id"||n==="argon2i"||n==="argon2d")return await X(r,{type:n==="argon2"?"argon2id":n,memory:t?.memory,time:t?.time});throw Error(`Unsupported hashing algorithm: ${n}`)}async function Ut(r,t){if(!r||!t)return!1;if(N(t)==="unknown")try{return await R(r,t)}catch{return!1}return await R(r,t)}async function z(r,t){let e=d(),n=t||e.bcrypt?.rounds||12;return await F(r,{algorithm:"bcrypt",cost:n})}async function X(r,t){let e=d(),n=t?.type||"argon2id",s=t?.memory||e.argon2?.memory||65536,o=t?.time||e.argon2?.time||2;return await F(r,{algorithm:n,memoryCost:s,timeCost:o})}function Bt(r,t){let e=Buffer.from(r),n=Buffer.from(t);if(e.length!==n.length)return K(e,e),!1;return K(e,n)}import{generateKey as j}from"ts-security-crypto";function Rt(){return j(32)}import{createHmac as J,randomBytes as Q,timingSafeEqual as O}from"crypto";class c extends Error{reason;constructor(r,t="mismatch"){super(r);this.name="InvalidWebhookSignature",this.reason=t}}function x(r){if(!/^[0-9a-f]*$/i.test(r)||r.length%2!==0)throw new c("Signature is not valid hex","malformed");return Buffer.from(r,"hex")}function v(r,t){if(r.length!==t.length)return O(r,r),!1;return O(r,t)}function y(r,t,e){return J(e,r).update(t).digest()}function Z(r,t){let e=r.split(","),n=Number.NaN,s=[];for(let o of e){let a=o.indexOf("=");if(a===-1)continue;let i=o.slice(0,a).trim(),u=o.slice(a+1).trim();if(i==="t")n=Number.parseInt(u,10);else if(i==="v1")s.push(u)}if(!Number.isFinite(n)||s.length===0)throw new c(`${t} header is malformed`,"malformed");return{timestamp:n,signatures:s}}function T(r,t,e,n,s){if(!r)throw new c(`${s.name} secret is empty`,"missing");if(!t)throw new c(`${s.header} header is missing`,"missing");let{timestamp:o,signatures:a}=Z(t,s.header),i=n.toleranceSeconds??300,u=Math.floor((n.now??Date.now())/1000);if(Math.abs(u-o)>i)throw new c(`${s.name} timestamp is outside tolerance window`,"expired");let m=typeof e==="string"?e:e.toString("utf8"),L=y(r,`${o}.${m}`,"sha256");for(let _ of a){let E;try{E=x(_)}catch{continue}if(v(L,E))return!0}throw new c(`${s.name} signature did not match`,"mismatch")}function tt(r,t,e,n={}){return T(r,t,e,n,{name:"Stripe webhook",header:"Stripe-Signature"})}function rt(r,t,e,n={}){return T(r,t,e,n,{name:"Webhook",header:"Signature"})}function Ot(r,t,e={}){let n=(Array.isArray(r)?r:[r]).filter((i)=>typeof i==="string"&&i.length>0);if(!n.length)throw new c("Webhook signing secret is empty","missing");let s=e.timestamp??Math.floor(Date.now()/1000);if(!Number.isInteger(s)||s<0)throw new c("Webhook timestamp must be whole unix seconds","malformed");let o=typeof t==="string"?t:t.toString("utf8"),a=n.map((i)=>y(i,`${s}.${o}`,"sha256").toString("hex"));return{timestamp:s,signatures:a,header:[`t=${s}`,...a.map((i)=>`v1=${i}`)].join(",")}}function Tt(r="whsec_"){return`${r}${Q(32).toString("base64url")}`}function et(r,t,e){if(!r)throw new c("GitHub webhook secret is empty","missing");if(!t)throw new c("X-Hub-Signature-256 header is missing","missing");let n="sha256=";if(!t.startsWith(n))throw new c("X-Hub-Signature-256 has wrong algorithm prefix","malformed");let s=x(t.slice(n.length)),o=y(r,e,"sha256");if(!v(o,s))throw new c("GitHub webhook signature did not match","mismatch");return!0}function nt(r,t,e,n="sha256"){if(!r)throw new c("HMAC secret is empty","missing");if(!e)throw new c("Signature header is missing","missing");let s=e.indexOf("="),o=s!==-1&&/^[a-z0-9]+$/i.test(e.slice(0,s))?e.slice(s+1):e,a=x(o),i=y(r,t,n);if(!v(i,a))throw new c("HMAC signature did not match","mismatch");return!0}function Ht(r,t,e,n,s={}){switch(r){case"stripe":return tt(t,e,n,s);case"github":return et(t,e,n);case"generic":return nt(t,n,e,s.algorithm??"sha256");case"signed":return rt(t,e,n,s);default:throw new c(`Unknown webhook provider: ${String(r)}`,"malformed")}}import{createHash as st,randomBytes as ot}from"crypto";function it(r){return st("sha256").update(r,"utf8").digest("hex")}function at(r,t=""){let e=t&&r.startsWith(t)?r.slice(t.length):r;if(e.length<=8)return`${t}\u2026`;return`${t}${e.slice(0,4)}\u2026${e.slice(-4)}`}function _t(r="",t=32){if(!Number.isInteger(t)||t<16)throw Error("An API key needs at least 16 random bytes");let e=`${r}${ot(t).toString("base64url")}`;return{key:e,hash:it(e),hint:at(e,r)}}import{lookup as ct}from"dns/promises";import{isIP as A}from"net";class p extends Error{reason;constructor(r,t){super(r);this.name="OutboundUrlError",this.reason=t}}function H(r){let t=r.split(".");if(t.length!==4)return null;let e=t.map((n)=>/^\d{1,3}$/.test(n)?Number(n):Number.NaN);return e.every((n)=>Number.isInteger(n)&&n>=0&&n<=255)?e:null}function w(r){let t=H(r);if(!t)return!0;let[e,n,s]=t;return e===0||e===10||e===100&&n>=64&&n<=127||e===127||e===169&&n===254||e===172&&n>=16&&n<=31||e===192&&n===0&&(s===0||s===2)||e===192&&n===88&&s===99||e===192&&n===168||e===198&&(n===18||n===19)||e===198&&n===51&&s===100||e===203&&n===0&&s===113||e>=224}function ut(r){let t=r.toLowerCase(),e=t.indexOf("%");if(e!==-1)t=t.slice(0,e);let n=t.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);if(n){let m=H(n[2]);if(!m)return null;t=`${n[1]}${(m[0]<<8|m[1]).toString(16)}:${(m[2]<<8|m[3]).toString(16)}`}let s=t.split("::");if(s.length>2)return null;let o=s[0]?s[0].split(":"):[],a=s.length===2&&s[1]?s[1].split(":"):[],i=8-o.length-a.length;if(s.length===1&&i!==0||i<0)return null;let u=[...o,...Array.from({length:s.length===2?i:0},()=>"0"),...a];if(u.length!==8||u.some((m)=>!/^[0-9a-f]{1,4}$/.test(m)))return null;return u.map((m)=>Number.parseInt(m,16))}function mt(r){let t=ut(r);if(!t)return!0;let e=`${t[6]>>8}.${t[6]&255}.${t[7]>>8}.${t[7]&255}`;if(t.slice(0,7).every((n)=>n===0))return!0;if(t.slice(0,5).every((n)=>n===0)&&t[5]===65535)return w(e);if(t[0]===100&&t[1]===65435&&t.slice(2,6).every((n)=>n===0))return w(e);if(t[0]===8194)return w(`${t[1]>>8}.${t[1]&255}.${t[2]>>8}.${t[2]&255}`);return(t[0]&65024)===64512||(t[0]&65472)===65152||(t[0]&65280)===65280||t[0]===8193&&t[1]===3512||t[0]===8193&&t[1]<512||t[0]===256}function M(r){let t=r.replace(/^\[|\]$/g,""),e=A(t.split("%")[0]);if(e===4)return w(t);if(e===6)return mt(t);return!0}var ft=[".localhost",".local",".internal",".localdomain",".home.arpa"];function gt(r,t={}){let e;try{e=new URL(r)}catch{return{reason:"invalid",message:"Not a valid URL"}}if(!(t.allowHttp?["https:","http:"]:["https:"]).includes(e.protocol))return{reason:"scheme",message:t.allowHttp?"The URL must start with https:// or http://":"The URL must start with https://"};if(e.username||e.password)return{reason:"credentials",message:"The URL must not contain a username or password"};if(t.allowPrivate)return null;let s=e.hostname.replace(/^\[|\]$/g,"").toLowerCase().replace(/\.$/,"");if(!s)return{reason:"invalid",message:"Not a valid URL"};if(A(s.split("%")[0]))return M(s)?{reason:"private",message:"The URL points at a private or local address"}:null;if(s==="localhost"||!s.includes(".")||ft.some((o)=>s.endsWith(o)))return{reason:"private",message:"The URL points at a private or local address"};return null}async function lt(r){return(await ct(r,{all:!0,verbatim:!0})).map((e)=>e.address)}async function It(r,t={}){let e=gt(r,t);if(e)throw new p(e.message,e.reason);let n=new URL(r);if(t.allowPrivate)return n;let s=n.hostname.replace(/^\[|\]$/g,"");if(A(s.split("%")[0]))return n;let o;try{o=await(t.resolve??lt)(s)}catch{throw new p(`Could not resolve ${s}`,"unresolvable")}if(!o.length)throw new p(`Could not resolve ${s}`,"unresolvable");if(o.some(M))throw new p("The URL points at a private or local address","private");return n}export{c as InvalidWebhookSignature,p as OutboundUrlError,$t as _resetCryptCacheForTests,at as apiKeyHint,X as argon2Encode,It as assertPublicOutboundUrl,Pt as base64Encode,z as bcryptEncode,Ut as check,xt as decrypt,N as detectAlgorithm,bt as encrypt,_t as generateApiKey,Rt as generateAppKey,Tt as generateWebhookSecret,it as hashApiKey,Ut as hashCheck,N as hashDetectAlgorithm,Y as hashInfo,kt as hashMake,St as hashNeedsRehash,Y as info,M as isPrivateAddress,wt as legacyEncrypt,kt as make,kt as makeHash,St as needsRehash,gt as outboundUrlProblem,Ot as signWebhook,Bt as timingSafeEqualString,et as verifyGithub,Ut as verifyHash,nt as verifyHmac,rt as verifySignedWebhook,tt as verifyStripe,Ht as verifyWebhook};
2
+ var rt=import.meta.require;import{Buffer as y}from"buffer";import{config as F}from"@stacksjs/config";import{decrypt as B,encrypt as Ot}from"ts-security-crypto";function H(r){return r.startsWith("base64:")?r.slice(7):r}var L=1,M=2,nt="stacks:encrypt:v2",O=600000,P=32,m=16,l=12,st=64,p=new Map;function ot(r,t,e){return`${e}:${y.from(t).toString("base64")}:${r}`}async function _(r,t,e){let n=ot(r,t,e),s=p.get(n);if(s)return s.hits++,p.delete(n),p.set(n,s),s.key;let o=await crypto.subtle.importKey("raw",new TextEncoder().encode(r),{name:"PBKDF2"},!1,["deriveKey"]),i=await crypto.subtle.deriveKey({name:"PBKDF2",salt:new Uint8Array(t),iterations:e,hash:"SHA-256"},o,{name:"AES-GCM",length:P*8},!1,["encrypt","decrypt"]);if(p.set(n,{key:i,hits:1}),p.size>st){let a=p.keys().next().value;if(a!==void 0)p.delete(a)}return i}function I(r){if(!r.startsWith("base64:"))return null;let t=y.from(r.slice(7),"base64");return t.length>=P?new Uint8Array(t):null}async function V(r,t){let e=await crypto.subtle.importKey("raw",new Uint8Array(r),"HKDF",!1,["deriveKey"]);return await crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:new Uint8Array(t),info:new TextEncoder().encode(nt)},e,{name:"AES-GCM",length:P*8},!1,["encrypt","decrypt"])}async function it(r,t){let e=crypto.getRandomValues(new Uint8Array(m)),n=crypto.getRandomValues(new Uint8Array(l)),s=await V(t,e),o=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:n},s,new TextEncoder().encode(r))),i=new Uint8Array(1+m+l+o.byteLength);return i[0]=M,i.set(e,1),i.set(n,1+m),i.set(o,1+m+l),y.from(i).toString("base64")}async function at(r,t){let e=y.from(r,"base64");if(e.length<1+m+l+16)return null;if(e[0]!==M)return null;let n=new Uint8Array(e.subarray(1,1+m)),s=new Uint8Array(e.subarray(1+m,1+m+l)),o=new Uint8Array(e.subarray(1+m+l)),i=await V(t,n);try{return new TextDecoder().decode(await crypto.subtle.decrypt({name:"AES-GCM",iv:s},i,o))}catch{return null}}async function ct(r,t){let e=crypto.getRandomValues(new Uint8Array(m)),n=crypto.getRandomValues(new Uint8Array(l)),s=await _(t,e,O),o=await crypto.subtle.encrypt({name:"AES-GCM",iv:n},s,new TextEncoder().encode(r)),i=new Uint8Array(o),a=new Uint8Array(1+m+l+i.byteLength);return a[0]=L,a.set(e,1),a.set(n,1+m),a.set(i,1+m+l),y.from(a).toString("base64")}async function ut(r,t){let e=y.from(r,"base64");if(e.length<1+m+l+16)return null;if(e[0]!==L)return null;let n=new Uint8Array(e.subarray(1,1+m)),s=new Uint8Array(e.subarray(1+m,1+m+l)),o=new Uint8Array(e.subarray(1+m+l)),i=await _(t,n,O);try{let a=await crypto.subtle.decrypt({name:"AES-GCM",iv:s},i,o);return new TextDecoder().decode(a)}catch{return null}}async function _t(r,t){if(!r&&r!=="")throw Error("encrypt() requires a string message");let e=t||F.app.key;if(!e)throw Error("APP_KEY is not defined");let n=I(e);if(n)return await it(r,n);return await ct(r,H(e))}async function It(r,t){if(!r)throw Error("decrypt() requires a non-empty encrypted string");let e=t||F.app.key;if(!e)throw Error("APP_KEY is not defined");let n=H(e),s=I(e);if(s){let i=await at(r,s);if(i!==null)return i}let o=await ut(r,n);if(o!==null)return o;try{return await B(r,n)}catch(i){if(n!==e)try{return await B(r,e)}catch{}throw i}}import{timingSafeEqual as D}from"crypto";import{base64Encode as Gt,hashPassword as G,verifyPassword as q}from"ts-security-crypto";var w=null;function R(){if(w)return w;try{let{hashing:r}=rt("@stacksjs/config");return w=r||{},w}catch{return console.warn("[Security] Failed to load hashing config, using defaults"),w={driver:"bcrypt",bcrypt:{rounds:12},argon2:{memory:65536,time:2}},w}}function W(r){if(!r||typeof r!=="string")return"unknown";if(r.startsWith("$2a$")||r.startsWith("$2b$")||r.startsWith("$2y$"))return"bcrypt";if(r.startsWith("$argon2id$"))return"argon2id";if(r.startsWith("$argon2i$"))return"argon2i";if(r.startsWith("$argon2d$"))return"argon2d";return"unknown"}function mt(r){let t=W(r),e={};if(t==="bcrypt"){let n=r.match(/^\$2[aby]\$(\d{2})\$/);if(n&&n[1]!==void 0)e.rounds=Number.parseInt(n[1],10)}else if(t==="argon2id"||t==="argon2i"||t==="argon2d"){let n=r.match(/v=(\d+)/),s=r.match(/m=(\d+)/),o=r.match(/t=(\d+)/),i=r.match(/p=(\d+)/);if(n&&n[1]!==void 0)e.version=Number.parseInt(n[1],10);if(s&&s[1]!==void 0)e.memory=Number.parseInt(s[1],10);if(o&&o[1]!==void 0)e.rounds=Number.parseInt(o[1],10);if(i&&i[1]!==void 0)e.parallelism=Number.parseInt(i[1],10)}return{algorithm:t,options:e}}function Wt(r,t){let e=mt(r),n=R(),s=t?.algorithm||n.driver||"bcrypt",o=(c)=>{if(c==="argon2")return"argon2id";return c},i=o(e.algorithm),a=o(s);if(i!==a)return!0;if(i==="bcrypt"){let c=t?.rounds||n.bcrypt?.rounds||12;if(e.options.rounds!==c)return!0}if(i.startsWith("argon2")){let c=t?.memory||n.argon2?.memory||65536,u=t?.time||n.argon2?.time||2;if(e.options.memory!==c||e.options.rounds!==u)return!0}return!1}async function Yt(r,t){let e=R(),n=t?.algorithm||e.driver||"bcrypt";if(n==="bcrypt")return await ft(r,t?.rounds);if(n==="argon2"||n==="argon2id"||n==="argon2i"||n==="argon2d")return await lt(r,{type:n==="argon2"?"argon2id":n,memory:t?.memory,time:t?.time});throw Error(`Unsupported hashing algorithm: ${n}`)}async function zt(r,t){if(!r||!t)return!1;if(W(t)==="unknown")try{return await q(r,t)}catch{return!1}return await q(r,t)}async function ft(r,t){let e=R(),n=t||e.bcrypt?.rounds||12;return await G(r,{algorithm:"bcrypt",cost:n})}async function lt(r,t){let e=R(),n=t?.type||"argon2id",s=t?.memory||e.argon2?.memory||65536,o=t?.time||e.argon2?.time||2;return await G(r,{algorithm:n,memoryCost:s,timeCost:o})}function Xt(r,t){let e=Buffer.from(r),n=Buffer.from(t);if(e.length!==n.length)return D(e,e),!1;return D(e,n)}import{generateKey as gt}from"ts-security-crypto";function Qt(){return gt(32)}import{createHmac as ht,randomBytes as dt,timingSafeEqual as Y}from"crypto";class f extends Error{reason;constructor(r,t="mismatch"){super(r);this.name="InvalidWebhookSignature",this.reason=t}}function k(r){if(!/^[0-9a-f]*$/i.test(r)||r.length%2!==0)throw new f("Signature is not valid hex","malformed");return Buffer.from(r,"hex")}function T(r,t){if(r.length!==t.length)return Y(r,r),!1;return Y(r,t)}function S(r,t,e){return ht(e,r).update(t).digest()}function pt(r,t){let e=r.split(","),n=Number.NaN,s=[];for(let o of e){let i=o.indexOf("=");if(i===-1)continue;let a=o.slice(0,i).trim(),c=o.slice(i+1).trim();if(a==="t")n=Number.parseInt(c,10);else if(a==="v1")s.push(c)}if(!Number.isFinite(n)||s.length===0)throw new f(`${t} header is malformed`,"malformed");return{timestamp:n,signatures:s}}function z(r,t,e,n,s){if(!r)throw new f(`${s.name} secret is empty`,"missing");if(!t)throw new f(`${s.header} header is missing`,"missing");let{timestamp:o,signatures:i}=pt(t,s.header),a=n.toleranceSeconds??300,c=Math.floor((n.now??Date.now())/1000);if(Math.abs(c-o)>a)throw new f(`${s.name} timestamp is outside tolerance window`,"expired");let u=typeof e==="string"?e:e.toString("utf8"),g=S(r,`${o}.${u}`,"sha256");for(let b of i){let x;try{x=k(b)}catch{continue}if(T(g,x))return!0}throw new f(`${s.name} signature did not match`,"mismatch")}function yt(r,t,e,n={}){return z(r,t,e,n,{name:"Stripe webhook",header:"Stripe-Signature"})}function wt(r,t,e,n={}){return z(r,t,e,n,{name:"Webhook",header:"Signature"})}function ee(r,t,e={}){let n=(Array.isArray(r)?r:[r]).filter((a)=>typeof a==="string"&&a.length>0);if(!n.length)throw new f("Webhook signing secret is empty","missing");let s=e.timestamp??Math.floor(Date.now()/1000);if(!Number.isInteger(s)||s<0)throw new f("Webhook timestamp must be whole unix seconds","malformed");let o=typeof t==="string"?t:t.toString("utf8"),i=n.map((a)=>S(a,`${s}.${o}`,"sha256").toString("hex"));return{timestamp:s,signatures:i,header:[`t=${s}`,...i.map((a)=>`v1=${a}`)].join(",")}}function re(r="whsec_"){return`${r}${dt(32).toString("base64url")}`}function bt(r,t,e){if(!r)throw new f("GitHub webhook secret is empty","missing");if(!t)throw new f("X-Hub-Signature-256 header is missing","missing");let n="sha256=";if(!t.startsWith(n))throw new f("X-Hub-Signature-256 has wrong algorithm prefix","malformed");let s=k(t.slice(n.length)),o=S(r,e,"sha256");if(!T(o,s))throw new f("GitHub webhook signature did not match","mismatch");return!0}function xt(r,t,e,n="sha256"){if(!r)throw new f("HMAC secret is empty","missing");if(!e)throw new f("Signature header is missing","missing");let s=e.indexOf("="),o=s!==-1&&/^[a-z0-9]+$/i.test(e.slice(0,s))?e.slice(s+1):e,i=k(o),a=S(r,t,n);if(!T(a,i))throw new f("HMAC signature did not match","mismatch");return!0}function ne(r,t,e,n,s={}){switch(r){case"stripe":return yt(t,e,n,s);case"github":return bt(t,e,n);case"generic":return xt(t,n,e,s.algorithm??"sha256");case"signed":return wt(t,e,n,s);default:throw new f(`Unknown webhook provider: ${String(r)}`,"malformed")}}import{createHash as At,randomBytes as vt}from"crypto";function $t(r){return At("sha256").update(r,"utf8").digest("hex")}function Ut(r,t=""){let e=t&&r.startsWith(t)?r.slice(t.length):r;if(e.length<=8)return`${t}\u2026`;return`${t}${e.slice(0,4)}\u2026${e.slice(-4)}`}function ie(r="",t=32){if(!Number.isInteger(t)||t<16)throw Error("An API key needs at least 16 random bytes");let e=`${r}${vt(t).toString("base64url")}`;return{key:e,hash:$t(e),hint:Ut(e,r)}}import{lookup as Rt}from"dns/promises";import{isIP as A}from"net";class h extends Error{reason;constructor(r,t){super(r);this.name="OutboundUrlError",this.reason=t}}function j(r){let t=r.split(".");if(t.length!==4)return null;let e=t.map((n)=>/^\d{1,3}$/.test(n)?Number(n):Number.NaN);return e.every((n)=>Number.isInteger(n)&&n>=0&&n<=255)?e:null}function E(r){let t=j(r);if(!t)return!0;let[e,n,s]=t;return e===0||e===10||e===100&&n>=64&&n<=127||e===127||e===169&&n===254||e===172&&n>=16&&n<=31||e===192&&n===0&&(s===0||s===2)||e===192&&n===88&&s===99||e===192&&n===168||e===198&&(n===18||n===19)||e===198&&n===51&&s===100||e===203&&n===0&&s===113||e>=224}function St(r){let t=r.toLowerCase(),e=t.indexOf("%");if(e!==-1)t=t.slice(0,e);let n=t.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);if(n){let u=j(n[2]);if(!u)return null;t=`${n[1]}${(u[0]<<8|u[1]).toString(16)}:${(u[2]<<8|u[3]).toString(16)}`}let s=t.split("::");if(s.length>2)return null;let o=s[0]?s[0].split(":"):[],i=s.length===2&&s[1]?s[1].split(":"):[],a=8-o.length-i.length;if(s.length===1&&a!==0||a<0)return null;let c=[...o,...Array.from({length:s.length===2?a:0},()=>"0"),...i];if(c.length!==8||c.some((u)=>!/^[0-9a-f]{1,4}$/.test(u)))return null;return c.map((u)=>Number.parseInt(u,16))}function Et(r){let t=St(r);if(!t)return!0;let e=`${t[6]>>8}.${t[6]&255}.${t[7]>>8}.${t[7]&255}`;if(t.slice(0,7).every((n)=>n===0))return!0;if(t.slice(0,5).every((n)=>n===0)&&t[5]===65535)return E(e);if(t[0]===100&&t[1]===65435&&t.slice(2,6).every((n)=>n===0))return E(e);if(t[0]===8194)return E(`${t[1]>>8}.${t[1]&255}.${t[2]>>8}.${t[2]&255}`);return(t[0]&65024)===64512||(t[0]&65472)===65152||(t[0]&65280)===65280||t[0]===8193&&t[1]===3512||t[0]===8193&&t[1]<512||t[0]===256}function C(r){let t=r.replace(/^\[|\]$/g,""),e=A(t.split("%")[0]);if(e===4)return E(t);if(e===6)return Et(t);return!0}var Pt=[".localhost",".local",".internal",".localdomain",".home.arpa"];function kt(r,t={}){let e;try{e=new URL(r)}catch{return{reason:"invalid",message:"Not a valid URL"}}if(!(t.allowHttp?["https:","http:"]:["https:"]).includes(e.protocol))return{reason:"scheme",message:t.allowHttp?"The URL must start with https:// or http://":"The URL must start with https://"};if(e.username||e.password)return{reason:"credentials",message:"The URL must not contain a username or password"};if(t.allowPrivate)return null;let s=e.hostname.replace(/^\[|\]$/g,"").toLowerCase().replace(/\.$/,"");if(!s)return{reason:"invalid",message:"Not a valid URL"};if(A(s.split("%")[0]))return C(s)?{reason:"private",message:"The URL points at a private or local address"}:null;if(s==="localhost"||!s.includes(".")||Pt.some((o)=>s.endsWith(o)))return{reason:"private",message:"The URL points at a private or local address"};return null}async function J(r){return(await Rt(r,{all:!0,verbatim:!0})).map((e)=>e.address)}async function Tt(r,t={}){let e=kt(r,t);if(e)throw new h(e.message,e.reason);let n=new URL(r);if(t.allowPrivate)return n;let s=n.hostname.replace(/^\[|\]$/g,"");if(A(s.split("%")[0]))return n;let o;try{o=await(t.resolve??J)(s)}catch{throw new h(`Could not resolve ${s}`,"unresolvable")}if(!o.length)throw new h(`Could not resolve ${s}`,"unresolvable");if(o.some(C))throw new h("The URL points at a private or local address","private");return n}async function Ct(r,t){let e=await Tt(r,t),n=e.hostname.replace(/^\[|\]$/g,"");if(A(n.split("%")[0])||t.allowPrivate)return{parsed:e,addresses:[]};let s=await(t.resolve??J)(n);return{parsed:e,addresses:s}}var Kt=new Set([301,302,303,307,308]),Nt=["authorization","cookie","proxy-authorization"];function Bt(r,t,e){if(!t)return[r.href,e];let n=new URL(r.href);n.hostname=A(t)===6?`[${t}]`:t;let s=new Headers(e.headers);s.set("Host",r.host);let o={...e,headers:s};if(r.protocol==="https:")o.tls={serverName:r.hostname.replace(/^\[|\]$/g,"")};return[n.href,o]}async function X(r,t){let e=Number(r.headers.get("content-length")??Number.NaN);if(Number.isFinite(e)&&e>t)throw new h(`The response is ${e} bytes, over the ${t} byte limit`,"too-large");if(!r.body)return r;let n=[],s=0,o=r.body.getReader();try{for(;;){let{done:c,value:u}=await o.read();if(c)break;if(s+=u.byteLength,s>t)throw await o.cancel(),new h(`The response is over the ${t} byte limit`,"too-large");n.push(u)}}finally{o.releaseLock()}let i=new Uint8Array(s),a=0;for(let c of n)i.set(c,a),a+=c.byteLength;return new Response(i,{status:r.status,statusText:r.statusText,headers:r.headers})}async function me(r,t={},e={}){let n=e.maxRedirects??3,s=e.maxBytes??5242880,o=e.timeoutMs??1e4,i=AbortSignal.timeout(o),a=t.signal?AbortSignal.any([t.signal,i]):i,c=t.body==null||typeof t.body==="string"||t.body instanceof Uint8Array||t.body instanceof ArrayBuffer,u=r,g={...t,signal:a,redirect:"manual"},b="";for(let x=0;;x++){let{parsed:v,addresses:Q}=await Ct(u,e);if(b&&v.origin!==b){let U=new Headers(g.headers);for(let et of Nt)U.delete(et);g={...g,headers:U}}b=v.origin;let[Z,tt]=Bt(v,Q.find((U)=>!C(U)),g),d=await fetch(Z,tt);if(!Kt.has(d.status))return await X(d,s);let K=d.headers.get("location");if(!K)return await X(d,s);if(x>=n)throw new h(`The URL redirected more than ${n} times`,"redirects");if(!c)throw new h("The URL redirected, and a streaming request body cannot be sent again","unreplayable-body");let N=(g.method??"GET").toUpperCase();if(d.status===303||(d.status===301||d.status===302)&&N!=="GET"&&N!=="HEAD")g={...g,method:"GET",body:void 0};u=new URL(K,v).href}}export{f as InvalidWebhookSignature,h as OutboundUrlError,Ut as apiKeyHint,lt as argon2Encode,Tt as assertPublicOutboundUrl,Gt as base64Encode,ft as bcryptEncode,zt as check,It as decrypt,W as detectAlgorithm,_t as encrypt,me as fetchPublicUrl,ie as generateApiKey,Qt as generateAppKey,re as generateWebhookSecret,$t as hashApiKey,zt as hashCheck,W as hashDetectAlgorithm,mt as hashInfo,Yt as hashMake,Wt as hashNeedsRehash,mt as info,C as isPrivateAddress,Ot as legacyEncrypt,Yt as make,Yt as makeHash,Wt as needsRehash,kt as outboundUrlProblem,ee as signWebhook,Xt as timingSafeEqualString,bt as verifyGithub,zt as verifyHash,xt as verifyHmac,wt as verifySignedWebhook,yt as verifyStripe,ne as verifyWebhook};
@@ -19,17 +19,52 @@ export declare function outboundUrlProblem(url: string, options?: OutboundUrlOpt
19
19
  * then every address its host resolves to must be public. Throws
20
20
  * {@link OutboundUrlError}; returns the parsed URL.
21
21
  *
22
- * The request itself resolves the name again, so a host that answers with a
23
- * public address here and a private one a moment later (DNS rebinding) is not
24
- * fully covered; a deployment that needs that also blocks private ranges at
25
- * its egress.
22
+ * **Blessing a URL is not the same as making a safe request, and this only
23
+ * does the first.** A caller that takes the returned URL and fetches it gives
24
+ * the guarantee straight back (stacksjs/stacks#2909):
25
+ *
26
+ * - `fetch` defaults to `redirect: 'follow'`, so a validated public host that
27
+ * answers `302 Location: http://169.254.169.254/...` is followed, and
28
+ * nothing checks the second request. Pass `redirect: 'manual'` and
29
+ * re-validate every hop, or the whole guard is one redirect wide.
30
+ * - `fetch` resolves the name again, so an address checked here is not the
31
+ * address connected to. A host answering public once and private a moment
32
+ * later (DNS rebinding) passes.
33
+ *
34
+ * Prefer {@link fetchPublicUrl}, which makes the request itself and handles
35
+ * both. Use this one when the check alone is what you want - validating a
36
+ * webhook URL at the moment someone saves it, say.
26
37
  */
27
38
  export declare function assertPublicOutboundUrl(url: string, options?: OutboundUrlOptions): Promise<URL>;
39
+ /**
40
+ * Fetch a URL somebody else supplied, checking every hop rather than the
41
+ * first one (stacksjs/stacks#2909).
42
+ *
43
+ * {@link assertPublicOutboundUrl} blesses a URL, and the caller's `fetch`
44
+ * then follows redirects and re-resolves the name, which hands back
45
+ * everything the check established. This makes the request itself:
46
+ *
47
+ * - every hop is validated by the same rules, up to `maxRedirects`
48
+ * - the connection goes to an address that was checked, with the name in
49
+ * `Host` and in TLS SNI, so a rebinding answer cannot be substituted
50
+ * - `Authorization`, `Cookie` and `Proxy-Authorization` are dropped when a
51
+ * redirect crosses to another origin
52
+ * - the body is capped and the whole chain is bounded by a timeout
53
+ *
54
+ * A streaming request body cannot be replayed onto a redirect, so a redirect
55
+ * is refused rather than silently sent without it.
56
+ */
57
+ export declare function fetchPublicUrl(url: string, init?: RequestInit, options?: PublicFetchOptions): Promise<Response>;
28
58
  export declare interface OutboundUrlOptions {
29
59
  allowHttp?: boolean
30
60
  allowPrivate?: boolean
31
61
  resolve?: (hostname: string) => Promise<string[]>
32
62
  }
63
+ export declare interface PublicFetchOptions extends OutboundUrlOptions {
64
+ maxRedirects?: number
65
+ timeoutMs?: number
66
+ maxBytes?: number
67
+ }
33
68
  /**
34
69
  * Guarding requests a server makes to URLs somebody else typed in: outgoing
35
70
  * webhooks, "import from URL", link previews. Without a guard such a URL is
@@ -37,7 +72,7 @@ export declare interface OutboundUrlOptions {
37
72
  * cloud metadata service, `http://localhost:5432/` the database.
38
73
  */
39
74
  /** Why an outbound URL was refused. */
40
- export type OutboundUrlReason = 'invalid' | 'scheme' | 'credentials' | 'private' | 'unresolvable';
75
+ export type OutboundUrlReason = 'invalid' | 'scheme' | 'credentials' | 'private' | 'unresolvable' | 'redirects' | 'too-large' | 'unreplayable-body';
41
76
  /** Thrown by {@link assertPublicOutboundUrl}. */
42
77
  export declare class OutboundUrlError extends Error {
43
78
  readonly reason: OutboundUrlReason;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/security",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.187",
5
+ "version": "0.75.188",
6
6
  "description": "The Stacks framework security.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -57,13 +57,13 @@
57
57
  "prepublishOnly": "bun run build"
58
58
  },
59
59
  "dependencies": {
60
- "@stacksjs/config": "0.75.187",
60
+ "@stacksjs/config": "0.75.188",
61
61
  "ts-security-crypto": "0.0.2"
62
62
  },
63
63
  "devDependencies": {
64
- "@stacksjs/env": "0.75.187",
65
- "@stacksjs/types": "0.75.187",
66
- "@stacksjs/validation": "0.75.187",
64
+ "@stacksjs/env": "0.75.188",
65
+ "@stacksjs/types": "0.75.188",
66
+ "@stacksjs/validation": "0.75.188",
67
67
  "better-dx": "^0.2.24"
68
68
  }
69
69
  }