url-sanitizer
Advanced tools
Comparing version 0.4.4 to 0.5.1
@@ -795,5 +795,6 @@ // src/mjs/common.js | ||
* @param {Array.<string>} opt.deny - array of denied schemes | ||
* @param {Array.<string>} opt.only - array of specific schemes to allow | ||
* @returns {?string} - sanitized URL | ||
*/ | ||
sanitize(url, opt = { allow: [], deny: [] }) { | ||
sanitize(url, opt = { allow: [], deny: [], only: [] }) { | ||
if (this.#nest > HEX) { | ||
@@ -805,3 +806,3 @@ this.#nest = 0; | ||
if (super.isURI(url)) { | ||
const { allow, deny } = opt ?? {}; | ||
const { allow, deny, only } = opt ?? {}; | ||
const { hash, href, pathname, protocol, search } = new URL(url); | ||
@@ -816,4 +817,8 @@ const scheme = protocol.replace(/:$/, ""); | ||
]); | ||
if (Array.isArray(allow) && allow.length) { | ||
const items = Object.values(allow); | ||
if (Array.isArray(only) && only.length) { | ||
const schemes = super.get(); | ||
for (const item of schemes) { | ||
schemeMap.set(item, false); | ||
} | ||
const items = Object.values(only); | ||
for (let item of items) { | ||
@@ -827,13 +832,25 @@ if (isString(item)) { | ||
} | ||
} | ||
if (Array.isArray(deny) && deny.length) { | ||
const items = Object.values(deny); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (item) { | ||
schemeMap.set(item, false); | ||
} else { | ||
if (Array.isArray(allow) && allow.length) { | ||
const items = Object.values(allow); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (!REG_SCRIPT.test(item)) { | ||
schemeMap.set(item, true); | ||
} | ||
} | ||
} | ||
} | ||
if (Array.isArray(deny) && deny.length) { | ||
const items = Object.values(deny); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (item) { | ||
schemeMap.set(item, false); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
@@ -926,2 +943,73 @@ let bool; | ||
} | ||
/** | ||
* object with extended props based on URL API | ||
* | ||
* @typedef {object} ParsedURL | ||
* @property {string} input - URL input | ||
* @property {boolean} valid - is valid URI | ||
* @property {object} data - parsed result of data URL, `null`able | ||
* @property {string} data.mime - MIME type | ||
* @property {boolean} data.base64 - `true` if base64 encoded | ||
* @property {string} data.data - data part of the data URL | ||
* @property {string} href - same as URL API | ||
* @property {string} origin - same as URL API | ||
* @property {string} protocol - same as URL API | ||
* @property {string} username - same as URL API | ||
* @property {string} password - same as URL API | ||
* @property {string} host - same as URL API | ||
* @property {string} hostname - same as URL API | ||
* @property {string} port - same as URL API | ||
* @property {string} pathname - same as URL API | ||
* @property {string} search - same as URL API | ||
* @property {object} searchParams - same as URL API | ||
* @property {string} hash - same as URL API | ||
*/ | ||
/** | ||
* parse sanitized URL | ||
* | ||
* @param {string} url - URL input | ||
* @returns {ParsedURL} - result with extended props based on URL API | ||
*/ | ||
parse(url) { | ||
if (!isString(url)) { | ||
throw new TypeError(`Expected String but got ${getType(url)}.`); | ||
} | ||
const sanitizedUrl = this.sanitize(url, { | ||
allow: ["data", "file"] | ||
}); | ||
const parsedUrl = /* @__PURE__ */ new Map([ | ||
["input", url] | ||
]); | ||
if (sanitizedUrl) { | ||
const urlObj = new URL(sanitizedUrl); | ||
const { pathname, protocol } = urlObj; | ||
const schemeParts = protocol.replace(/:$/, "").split("+"); | ||
parsedUrl.set("valid", true); | ||
if (schemeParts.includes("data")) { | ||
const dataUrl = /* @__PURE__ */ new Map(); | ||
const [head, ...body] = pathname.split(","); | ||
const data = `${body.join(",")}`; | ||
const mediaType = head.split(";"); | ||
const isBase64 = mediaType[mediaType.length - 1] === "base64"; | ||
if (isBase64) { | ||
mediaType.pop(); | ||
} | ||
dataUrl.set("mime", mediaType.join(";")); | ||
dataUrl.set("base64", isBase64); | ||
dataUrl.set("data", data); | ||
parsedUrl.set("data", Object.fromEntries(dataUrl)); | ||
} else { | ||
parsedUrl.set("data", null); | ||
} | ||
for (const key in urlObj) { | ||
const value = urlObj[key]; | ||
if (typeof value !== "function") { | ||
parsedUrl.set(key, value); | ||
} | ||
} | ||
} else { | ||
parsedUrl.set("valid", false); | ||
} | ||
return Object.fromEntries(parsedUrl); | ||
} | ||
}; | ||
@@ -936,3 +1024,4 @@ var urlSanitizer = new URLSanitizer(); | ||
allow: [], | ||
deny: [] | ||
deny: [], | ||
only: [] | ||
}); | ||
@@ -939,0 +1028,0 @@ var sanitizeURL = async (url, opt) => { |
@@ -1,2 +0,2 @@ | ||
var g=t=>Object.prototype.toString.call(t).slice(8,-1),m=t=>typeof t=="string"||t instanceof String;var k=[7,8,9,10,11,12,13,27,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255];var P=["aaa","aaas","about","acap","acct","acd","acr","adiumxtra","adt","afp","afs","aim","amss","android","appdata","apt","ar","ark","attachment","aw","barion","beshare","bitcoin","bitcoincash","blob","bolo","browserext","cabal","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cvs","dab","dat","data","dav","diaspora","dict","did","dis","dlna-playcontainer","dlna-playsingle","dns","dntp","doi","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","ethereum","example","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","http","https","hxxp","hxxps","hydrazone","hyper","iax","icap","icon","im","imap","info","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lorawan","lpa","lvlt","magnet","mailto","maps","market","matrix","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-powerpoint","ms-project","ms-publisher","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mumble","mupdate","mvn","news","nfs","ni","nih","nntp","notes","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openpgp4fpr","otpauth","palm","paparazzi","payment","payto","pkcs11","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","sarif","secondlife","secret-token","service","session","sftp","sgn","shc","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spiffe","spotify","ssb","ssh","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","tel","teliaeid","telnet","tftp","things","thismessage","tip","tn3270","tool","turn","turns","tv","udp","unreal","urn","ut2004","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcon","xcon-userid","xfire","xmlrpc.beep","xmlrpc.beeps","xmpp","xri","ymsgr","z39.50r","z39.50s"];var u=16,J=/^[\da-z+/\-_=]+$/i,K=/data:[^,]*,[^"]+/g,G=/data:[^,]*;?base64,[\da-z+/\-_=]+/i,Q=/[<>"'\s]/g,V=/%(?:2(?:2|7)|3(?:C|E))/g,W=/&#(x(?:00)?[\dA-F]{2}|0?\d{1,3});?/ig,Z=/^[a-z][\da-z+\-.]*$/,ee=/^(?:ext|web)\+[a-z]+$/,x=/(?:java|vb)script/,te=/^%[\dA-F]{2}$/i,se=/%26/g,T=t=>{if(!m(t))throw new TypeError(`Expected String but got ${g(t)}.`);let e=[];for(let s of t)e.push(`%${s.charCodeAt(0).toString(u).toUpperCase()}`);return e.join("")},M=t=>{if(m(t))if(te.test(t))t=t.toUpperCase();else throw new Error(`Invalid URL encoded character: ${t}`);else throw new TypeError(`Expected String but got ${g(t)}.`);let[e,s,a,i,o,f]=["&","#","<",">",'"',"'"].map(T),n;return t===e?n=`${e}amp;`:t===a?n=`${e}lt;`:t===i?n=`${e}gt;`:t===o?n=`${e}quot;`:t===f?n=`${e}${s}39;`:n=t,n},re=t=>{if(m(t)){if(!J.test(t))throw new Error(`Invalid base64 data: ${t}`)}else throw new TypeError(`Expected String but got ${g(t)}.`);let e=atob(t),s=Uint8Array.from([...e].map(o=>o.charCodeAt(0))),a=new Set(k),i;return s.every(o=>a.has(o))?i=e.replace(/\s/g,T):i=t,i},N=(t,e=0)=>{if(!m(t))throw new TypeError(`Expected String but got ${g(t)}.`);if(Number.isInteger(e)){if(e>u)throw new Error("Character references nested too deeply.")}else throw new TypeError(`Expected Number but got ${g(e)}.`);let s=decodeURIComponent(t);if(/&#/.test(s)){let a=new Set(k),i=[...s.matchAll(W)].reverse();for(let o of i){let[f,n]=o,l;if(/^x[\dA-F]+/i.test(n)?l=parseInt(`0${n}`,u):/^[\d]+/.test(n)&&(l=parseInt(n)),Number.isInteger(l)){let{index:b}=o,[w,h]=[s.substring(0,b),s.substring(b+f.length)];a.has(l)?(s=`${w}${String.fromCharCode(l)}${h}`,(/#x?$/.test(w)||/^#(?:x(?:00)?[2-7]|\d)/.test(h))&&(s=N(s,++e))):l<u*u&&(s=`${w}${h}`)}}}return s},C=class{#e;constructor(){this.#e=new Set(P)}get(){return[...this.#e]}has(e){return this.#e.has(e)}add(e){if(m(e)){if(x.test(e)||!Z.test(e))throw new Error(`Invalid scheme: ${e}`)}else throw new TypeError(`Expected String but got ${g(e)}.`);return this.#e.add(e),[...this.#e]}remove(e){return this.#e.delete(e)}isURI(e){let s;if(m(e))try{let{protocol:a}=new URL(e),i=a.replace(/:$/,""),o=i.split("+");s=!x.test(i)&&ee.test(i)||o.every(f=>this.#e.has(f))}catch{s=!1}return!!s}},z=class extends C{#e;#t;constructor(){super(),this.#e=0,this.#t=new Set}sanitize(e,s={allow:[],deny:[]}){if(this.#e>u)throw this.#e=0,new Error("Data URLs nested too deeply.");let a;if(super.isURI(e)){let{allow:i,deny:o}=s??{},{hash:f,href:n,pathname:l,protocol:b,search:w}=new URL(e),h=b.replace(/:$/,""),j=h.split("+"),v=new Map([["data",!1],["file",!1],["javascrpt",!1],["vbscript",!1]]);if(Array.isArray(i)&&i.length){let c=Object.values(i);for(let r of c)m(r)&&(r=r.trim(),x.test(r)||v.set(r,!0))}if(Array.isArray(o)&&o.length){let c=Object.values(o);for(let r of c)m(r)&&(r=r.trim(),r&&v.set(r,!1))}let E;for(let[c,r]of v.entries())if(E=r||h!==c&&j.every($=>$!==c),!E)break;if(E){let c,r=n;if(j.includes("data")){let[$,...H]=l.split(","),U=`${H.join(",")}${w}${f}`,y=$.split(";"),p=U;if(y[y.length-1]==="base64")y.pop(),p=re(U);else try{let R=N(p),{protocol:S}=new URL(R.trim());S.replace(/:$/,"").split("+").some(d=>x.test(d))&&(r="")}catch{}let I=/data:[^,]*,/.test(p);if(p!==U||I){if(I){let S=[...p.matchAll(K)].reverse();for(let _ of S){let[d]=_;G.test(d)&&([d]=G.exec(d)),this.#e++,this.#t.add(d);let L=this.sanitize(d,{allow:["data"]});if(L){let{index:D}=_,[F,Y]=[p.substring(0,D),p.substring(D+d.length)];p=`${F}${L}${Y}`}}this.#t.has(e)?this.#t.delete(e):c=!0}else this.#t.has(e)?this.#t.delete(e):c=!0;r=`${h}:${y.join(";")},${p}`}else this.#t.has(e)?this.#t.delete(e):c=!0}else c=!0;r?(a=r.replace(Q,T).replace(se,M),c&&(a=a.replace(V,M),this.#e=0)):(a=r,this.#e=0)}}return a||null}},A=new z,O=t=>A.isURI(t),ie=async t=>await O(t),q=(t,e)=>A.sanitize(t,e??{allow:[],deny:[]}),ae=async(t,e)=>await q(t,e);export{A as default,ie as isURI,O as isURISync,ae as sanitizeURL,q as sanitizeURLSync}; | ||
var w=t=>Object.prototype.toString.call(t).slice(8,-1),d=t=>typeof t=="string"||t instanceof String;var C=[7,8,9,10,11,12,13,27,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255];var D=["aaa","aaas","about","acap","acct","acd","acr","adiumxtra","adt","afp","afs","aim","amss","android","appdata","apt","ar","ark","attachment","aw","barion","beshare","bitcoin","bitcoincash","blob","bolo","browserext","cabal","calculator","callto","cap","cast","casts","chrome","chrome-extension","cid","coap","coaps","com-eventbrite-attendee","content","content-type","crid","cstr","cvs","dab","dat","data","dav","diaspora","dict","did","dis","dlna-playcontainer","dlna-playsingle","dns","dntp","doi","dpp","drm","dtmi","dtn","dvb","dvx","dweb","ed2k","eid","elsi","embedded","ens","ethereum","example","facetime","feed","feedready","fido","file","finger","first-run-pen-experience","fish","fm","ftp","fuchsia-pkg","geo","gg","git","gitoid","gizmoproject","go","gopher","graph","gtalk","h323","ham","hcap","hcp","http","https","hxxp","hxxps","hydrazone","hyper","iax","icap","icon","im","imap","info","iotdisco","ipfs","ipn","ipns","ipp","ipps","irc","irc6","ircs","iris","iris.beep","iris.lwz","iris.xpc","iris.xpcs","isostore","itms","jabber","jar","jms","keyparc","lastfm","lbry","ldap","ldaps","leaptofrogans","lorawan","lpa","lvlt","magnet","mailto","maps","market","matrix","message","microsoft.windows.camera","microsoft.windows.camera.multipicker","microsoft.windows.camera.picker","mid","mms","mongodb","moz","moz-extension","ms-access","ms-appinstaller","ms-browser-extension","ms-calculator","ms-drive-to","ms-enrollment","ms-excel","ms-eyecontrolspeech","ms-gamebarservices","ms-gamingoverlay","ms-getoffice","ms-help","ms-infopath","ms-inputapp","ms-lockscreencomponent-config","ms-media-stream-id","ms-meetnow","ms-mixedrealitycapture","ms-mobileplans","ms-newsandinterests","ms-officeapp","ms-people","ms-powerpoint","ms-project","ms-publisher","ms-remotedesktop-launch","ms-restoretabcompanion","ms-screenclip","ms-screensketch","ms-search","ms-search-repair","ms-secondary-screen-controller","ms-secondary-screen-setup","ms-settings","ms-settings-airplanemode","ms-settings-bluetooth","ms-settings-camera","ms-settings-cellular","ms-settings-cloudstorage","ms-settings-connectabledevices","ms-settings-displays-topology","ms-settings-emailandaccounts","ms-settings-language","ms-settings-location","ms-settings-lock","ms-settings-nfctransactions","ms-settings-notifications","ms-settings-power","ms-settings-privacy","ms-settings-proximity","ms-settings-screenrotation","ms-settings-wifi","ms-settings-workplace","ms-spd","ms-stickers","ms-sttoverlay","ms-transit-to","ms-useractivityset","ms-virtualtouchpad","ms-visio","ms-walk-to","ms-whiteboard","ms-whiteboard-cmd","ms-word","msnim","msrp","msrps","mss","mt","mtqp","mumble","mupdate","mvn","news","nfs","ni","nih","nntp","notes","num","ocf","oid","onenote","onenote-cmd","opaquelocktoken","openpgp4fpr","otpauth","palm","paparazzi","payment","payto","pkcs11","platform","pop","pres","proxy","psyc","pttp","pwid","qb","query","quic-transport","redis","rediss","reload","res","resource","rmi","rsync","rtmfp","rtmp","rtsp","rtsps","rtspu","sarif","secondlife","secret-token","service","session","sftp","sgn","shc","sieve","simpleledger","simplex","sip","sips","skype","smb","smp","sms","smtp","snmp","soap.beep","soap.beeps","soldat","spiffe","spotify","ssb","ssh","starknet","steam","stun","stuns","submit","svn","swh","swid","swidpath","tag","taler","teamspeak","tel","teliaeid","telnet","tftp","things","thismessage","tip","tn3270","tool","turn","turns","tv","udp","unreal","urn","ut2004","uuid-in-package","v-event","vemmi","ventrilo","ves","view-source","vnc","vscode","vscode-insiders","vsls","w3","wcr","web3","webcal","wifi","ws","wss","wtai","wyciwyg","xcon","xcon-userid","xfire","xmlrpc.beep","xmlrpc.beeps","xmpp","xri","ymsgr","z39.50r","z39.50s"];var x=16,K=/^[\da-z+/\-_=]+$/i,Q=/data:[^,]*,[^"]+/g,O=/data:[^,]*;?base64,[\da-z+/\-_=]+/i,V=/[<>"'\s]/g,W=/%(?:2(?:2|7)|3(?:C|E))/g,Z=/&#(x(?:00)?[\dA-F]{2}|0?\d{1,3});?/ig,ee=/^[a-z][\da-z+\-.]*$/,te=/^(?:ext|web)\+[a-z]+$/,U=/(?:java|vb)script/,se=/^%[\dA-F]{2}$/i,re=/%26/g,A=t=>{if(!d(t))throw new TypeError(`Expected String but got ${w(t)}.`);let e=[];for(let s of t)e.push(`%${s.charCodeAt(0).toString(x).toUpperCase()}`);return e.join("")},G=t=>{if(d(t))if(se.test(t))t=t.toUpperCase();else throw new Error(`Invalid URL encoded character: ${t}`);else throw new TypeError(`Expected String but got ${w(t)}.`);let[e,s,r,i,o,l]=["&","#","<",">",'"',"'"].map(A),n;return t===e?n=`${e}amp;`:t===r?n=`${e}lt;`:t===i?n=`${e}gt;`:t===o?n=`${e}quot;`:t===l?n=`${e}${s}39;`:n=t,n},ae=t=>{if(d(t)){if(!K.test(t))throw new Error(`Invalid base64 data: ${t}`)}else throw new TypeError(`Expected String but got ${w(t)}.`);let e=atob(t),s=Uint8Array.from([...e].map(o=>o.charCodeAt(0))),r=new Set(C),i;return s.every(o=>r.has(o))?i=e.replace(/\s/g,A):i=t,i},N=(t,e=0)=>{if(!d(t))throw new TypeError(`Expected String but got ${w(t)}.`);if(Number.isInteger(e)){if(e>x)throw new Error("Character references nested too deeply.")}else throw new TypeError(`Expected Number but got ${w(e)}.`);let s=decodeURIComponent(t);if(/&#/.test(s)){let r=new Set(C),i=[...s.matchAll(Z)].reverse();for(let o of i){let[l,n]=o,c;if(/^x[\dA-F]+/i.test(n)?c=parseInt(`0${n}`,x):/^[\d]+/.test(n)&&(c=parseInt(n)),Number.isInteger(c)){let{index:f}=o,[y,b]=[s.substring(0,f),s.substring(f+l.length)];r.has(c)?(s=`${y}${String.fromCharCode(c)}${b}`,(/#x?$/.test(y)||/^#(?:x(?:00)?[2-7]|\d)/.test(b))&&(s=N(s,++e))):c<x*x&&(s=`${y}${b}`)}}}return s},T=class{#e;constructor(){this.#e=new Set(D)}get(){return[...this.#e]}has(e){return this.#e.has(e)}add(e){if(d(e)){if(U.test(e)||!ee.test(e))throw new Error(`Invalid scheme: ${e}`)}else throw new TypeError(`Expected String but got ${w(e)}.`);return this.#e.add(e),[...this.#e]}remove(e){return this.#e.delete(e)}isURI(e){let s;if(d(e))try{let{protocol:r}=new URL(e),i=r.replace(/:$/,""),o=i.split("+");s=!U.test(i)&&te.test(i)||o.every(l=>this.#e.has(l))}catch{s=!1}return!!s}},j=class extends T{#e;#t;constructor(){super(),this.#e=0,this.#t=new Set}sanitize(e,s={allow:[],deny:[],only:[]}){if(this.#e>x)throw this.#e=0,new Error("Data URLs nested too deeply.");let r;if(super.isURI(e)){let{allow:i,deny:o,only:l}=s??{},{hash:n,href:c,pathname:f,protocol:y,search:b}=new URL(e),g=y.replace(/:$/,""),v=g.split("+"),E=new Map([["data",!1],["file",!1],["javascrpt",!1],["vbscript",!1]]);if(Array.isArray(l)&&l.length){let p=super.get();for(let m of p)E.set(m,!1);let a=Object.values(l);for(let m of a)d(m)&&(m=m.trim(),U.test(m)||E.set(m,!0))}else{if(Array.isArray(i)&&i.length){let p=Object.values(i);for(let a of p)d(a)&&(a=a.trim(),U.test(a)||E.set(a,!0))}if(Array.isArray(o)&&o.length){let p=Object.values(o);for(let a of p)d(a)&&(a=a.trim(),a&&E.set(a,!1))}}let R;for(let[p,a]of E.entries())if(R=a||g!==p&&v.every(m=>m!==p),!R)break;if(R){let p,a=c;if(v.includes("data")){let[m,...F]=f.split(","),S=`${F.join(",")}${b}${n}`,$=m.split(";"),h=S;if($[$.length-1]==="base64")$.pop(),h=ae(S);else try{let _=N(h),{protocol:k}=new URL(_.trim());k.replace(/:$/,"").split("+").some(u=>U.test(u))&&(a="")}catch{}let I=/data:[^,]*,/.test(h);if(h!==S||I){if(I){let k=[...h.matchAll(Q)].reverse();for(let z of k){let[u]=z;O.test(u)&&([u]=O.exec(u)),this.#e++,this.#t.add(u);let M=this.sanitize(u,{allow:["data"]});if(M){let{index:P}=z,[B,Y]=[h.substring(0,P),h.substring(P+u.length)];h=`${B}${M}${Y}`}}this.#t.has(e)?this.#t.delete(e):p=!0}else this.#t.has(e)?this.#t.delete(e):p=!0;a=`${g}:${$.join(";")},${h}`}else this.#t.has(e)?this.#t.delete(e):p=!0}else p=!0;a?(r=a.replace(V,A).replace(re,G),p&&(r=r.replace(W,G),this.#e=0)):(r=a,this.#e=0)}}return r||null}parse(e){if(!d(e))throw new TypeError(`Expected String but got ${w(e)}.`);let s=this.sanitize(e,{allow:["data","file"]}),r=new Map([["input",e]]);if(s){let i=new URL(s),{pathname:o,protocol:l}=i,n=l.replace(/:$/,"").split("+");if(r.set("valid",!0),n.includes("data")){let c=new Map,[f,...y]=o.split(","),b=`${y.join(",")}`,g=f.split(";"),v=g[g.length-1]==="base64";v&&g.pop(),c.set("mime",g.join(";")),c.set("base64",v),c.set("data",b),r.set("data",Object.fromEntries(c))}else r.set("data",null);for(let c in i){let f=i[c];typeof f!="function"&&r.set(c,f)}}else r.set("valid",!1);return Object.fromEntries(r)}},L=new j,q=t=>L.isURI(t),ie=async t=>await q(t),H=(t,e)=>L.sanitize(t,e??{allow:[],deny:[],only:[]}),oe=async(t,e)=>await H(t,e);export{L as default,ie as isURI,q as isURISync,oe as sanitizeURL,H as sanitizeURLSync}; | ||
//# sourceMappingURL=url-sanitizer.min.js.map |
@@ -53,3 +53,3 @@ { | ||
}, | ||
"version": "0.4.4" | ||
"version": "0.5.1" | ||
} |
176
README.md
@@ -11,2 +11,3 @@ # URL Sanitizer | ||
## Install | ||
@@ -24,2 +25,3 @@ | ||
## Usage | ||
@@ -29,6 +31,7 @@ | ||
import urlSanitizer, { | ||
isURI, isURISync, sanitizeURL, sanitizeURLSync | ||
isURI, isURISync, parseURL, parseURLSync, sanitizeURL, sanitizeURLSync | ||
} from 'url-sanitizer'; | ||
``` | ||
## sanitizeURL(url, opt) | ||
@@ -43,4 +46,6 @@ | ||
* `opt` **[object][3]** Options. | ||
* `opt.allow` **[Array][4]<[string][1]>** Array of allowed schemes, e.g. `['data']`. | ||
* `opt.deny` **[Array][4]<[string][1]>** Array of denied schemes, e.g. `['web+foo']`. | ||
* `opt.allow` **[Array][4]<[string][1]>** Array of allowed schemes, e.g. `['data']`. | ||
* `opt.deny` **[Array][4]<[string][1]>** Array of denied schemes, e.g. `['web+foo']`. | ||
* `opt.only` **[Array][4]<[string][1]>** Array of specific schemes to allow, e.g. `['git', 'https']`. | ||
`only` takes precedence over `allow` and `deny`. | ||
@@ -52,3 +57,3 @@ Returns **[Promise][5]<[string][1]?>** Sanitized URL, `null`able. | ||
.then(res => decodeURIComponent(res)); | ||
// -> 'http://example.com/?<script>alert(1);</script>' | ||
// => 'http://example.com/?<script>alert(1);</script>' | ||
@@ -58,3 +63,3 @@ const res2 = await sanitizeURL('data:text/html,<script>alert(1);</script>', { | ||
}).then(res => decodeURIComponent(res)); | ||
// -> 'data:text/html,<script>alert(1);</script>' | ||
// => 'data:text/html,<script>alert(1);</script>' | ||
@@ -66,3 +71,3 @@ // Can parse and sanitize base64 encoded data | ||
}).then(res => decodeURIComponent(res)); | ||
// -> 'data:text/html,<script>alert(1);</script>' | ||
// => 'data:text/html,<script>alert(1);</script>' | ||
@@ -72,5 +77,27 @@ const res4 = await sanitizeURL('web+foo://example.com', { | ||
}); | ||
// -> null | ||
// => null | ||
const res5 = await sanitizeURL('http://example.com', { | ||
only: ['data', 'git', 'https'] | ||
}); | ||
// => null | ||
const res6 = await sanitizeURL('https://example.com/"onmouseover="alert(1)"', { | ||
only: ['data', 'git', 'https'] | ||
}).then(res => decodeURIComponent(res)); | ||
// => https://example.com/"onmouseover="alert(1)" | ||
const res7 = await sanitizeURL('data:text/html,<script>alert(1);</script>', { | ||
only: ['data', 'git', 'https'] | ||
}).then(res => decodeURIComponent(res)); | ||
// => 'data:text/html,<script>alert(1);</script>' | ||
// `only` option also allows combinations of the specified schemes | ||
const res8 = await sanitizeURL('git+https://example.com', { | ||
only: ['data', 'git', 'https'] | ||
}).then(res => decodeURIComponent(res));; | ||
// => git+https://example.com | ||
``` | ||
## sanitizeURLSync | ||
@@ -80,2 +107,101 @@ | ||
## parseURL(url) | ||
Parse the given URL. | ||
### Parameters | ||
* `url` **[string][1]** URL input. | ||
Returns **[Promise][5]<[ParsedURL](#parsedurl)>** Result. | ||
### ParsedURL | ||
Object with extended properties based on [URL](https://developer.mozilla.org/ja/docs/Web/API/URL) API. | ||
Type: [object][3] | ||
#### Properties | ||
* `input` **[string][1]** URL input. | ||
* `valid` **[boolean][2]** Is valid URI. | ||
* `data` **[object][3]** Parsed result of data URL, `null`able. | ||
* `data.mime` **[string][1]** MIME type. | ||
* `data.base64` **[boolean][2]** `true` if base64 encoded. | ||
* `data.data` **[string][1]** Data part of the data URL. | ||
* `href` **[string][1]** Same as URL API. | ||
* `origin` **[string][1]** Same as URL API. | ||
* `protocol` **[string][1]** Same as URL API. | ||
* `username` **[string][1]** Same as URL API. | ||
* `password` **[string][1]** Same as URL API. | ||
* `host` **[string][1]** Same as URL API. | ||
* `hostname` **[string][1]** Same as URL API. | ||
* `port` **[string][1]** Same as URL API. | ||
* `pathname` **[string][1]** Same as URL API. | ||
* `search` **[string][1]** Same as URL API. | ||
* `searchParams` **[object][3]** Same as URL API. | ||
* `hash` **[string][1]** Same as URL API. | ||
```javascript | ||
const res1 = await parseURL('javascript:alert(1)'); | ||
/* => { | ||
input: 'javascript:alert(1)', | ||
valid: false | ||
} */ | ||
const res2 = await parseURL('https://example.com/?foo=bar#baz'); | ||
/* => { | ||
input: 'https://www.example.com/?foo=bar#baz', | ||
valid: true, | ||
data: null, | ||
href: 'https://www.example.com/?foo=bar#baz', | ||
origin: 'https://www.example.com', | ||
protocol: 'https:', | ||
hostname: 'www.example.com', | ||
pathname: '/', | ||
search: '?foo=bar', | ||
hash: '#baz', | ||
... | ||
} */ | ||
// base64 encoded svg '<svg><g onload="alert(1)"/></svg>' | ||
const res3 = await parseURL('data:image/svg+xml;base64,PHN2Zz48ZyBvbmxvYWQ9ImFsZXJ0KDEpIi8+PC9zdmc+'); | ||
/* => { | ||
input: 'data:image/svg+xml;base64,PHN2Zz48ZyBvbmxvYWQ9ImFsZXJ0KDEpIi8+PC9zdmc+', | ||
valid: true, | ||
data: { | ||
mime: 'image/svg+xml', | ||
base64: false, | ||
data: '%26lt;svg%26gt;%26lt;g%20onload=%26quot;alert(1)%26quot;/%26gt;%26lt;/svg%26gt;' | ||
}, | ||
href: 'data:image/svg+xml,%26lt;svg%26gt;%26lt;g%20onload=%26quot;alert(1)%26quot;/%26gt;%26lt;/svg%26gt;', | ||
protocol: 'data:', | ||
pathname: 'image/svg+xml,%26lt;svg%26gt;%26lt;g%20onload=%26quot;alert(1)%26quot;/%26gt;%26lt;/svg%26gt;', | ||
... | ||
} */ | ||
// base64 encoded png | ||
const res4 = await parseURL('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='); | ||
/* => { | ||
input: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', | ||
valid: true, | ||
data: { | ||
mime: 'image/png', | ||
base64: true, | ||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==' | ||
}, | ||
href: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', | ||
protocol: 'data:', | ||
pathname: 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==', | ||
... | ||
} */ | ||
``` | ||
## parseURLSync(url) | ||
Synchronous version of the [parseURL()](#parseURL). | ||
## isURI(uri) | ||
@@ -90,18 +216,22 @@ | ||
Returns **[Promise][5]<[boolean][2]>** Result. | ||
* Always `true` for `web+*` and `ext+*` schemes. | ||
* Always `true` for `web+*` and `ext+*` schemes, except `web+javascript`, `web+vbscript`, `ext+javascript`, `ext+vbscript`. | ||
```javascript | ||
const res1 = await isURI('https://example.com/foo'); | ||
// -> true | ||
// => true | ||
const res2 = await isURI('mailto:foo@example.com'); | ||
// -> true | ||
// => true | ||
const res3 = await isURI('foo:bar'); | ||
// -> false | ||
// => false | ||
const res4 = await isURI('web+foo:bar'); | ||
// -> true | ||
// => true | ||
const res5 = await isURI('web+javascript:alert(1)'); | ||
// => false | ||
``` | ||
## isURISync(uri) | ||
@@ -111,2 +241,4 @@ | ||
--- | ||
## urlSanitizer | ||
@@ -126,3 +258,3 @@ | ||
const schemes = urlSanitizer.get(); | ||
// -> ['aaa', 'aaas', 'about', 'acap', 'acct', ...]; | ||
// => ['aaa', 'aaas', 'about', 'acap', 'acct', ...]; | ||
``` | ||
@@ -142,6 +274,6 @@ | ||
const res1 = urlSanitizer.has('https'); | ||
// -> true | ||
// => true | ||
const res2 = urlSanitizer.has('foo'); | ||
// -> false | ||
// => false | ||
``` | ||
@@ -162,9 +294,9 @@ | ||
console.log(isURISync('foo')); | ||
// -> false; | ||
// => false; | ||
const res = urlSanitizer.add('foo'); | ||
// -> ['aaa', 'aaas', 'about', 'acap', ... 'foo', ...]; | ||
// => ['aaa', 'aaas', 'about', 'acap', ... 'foo', ...]; | ||
console.log(isURISync('foo')); | ||
// -> true; | ||
// => true; | ||
``` | ||
@@ -185,12 +317,12 @@ | ||
console.log(isURISync('aaa')); | ||
// -> true; | ||
// => true; | ||
const res1 = urlSanitizer.remove('aaa'); | ||
// -> true | ||
// => true | ||
console.log(isURISync('aaa')); | ||
// -> false; | ||
// => false; | ||
const res2 = urlSanitizer.remove('foo'); | ||
// -> false | ||
// => false | ||
``` | ||
@@ -197,0 +329,0 @@ |
@@ -259,5 +259,6 @@ /** | ||
* @param {Array.<string>} opt.deny - array of denied schemes | ||
* @param {Array.<string>} opt.only - array of specific schemes to allow | ||
* @returns {?string} - sanitized URL | ||
*/ | ||
sanitize(url, opt = { allow: [], deny: [] }) { | ||
sanitize(url, opt = { allow: [], deny: [], only: [] }) { | ||
if (this.#nest > HEX) { | ||
@@ -269,3 +270,3 @@ this.#nest = 0; | ||
if (super.isURI(url)) { | ||
const { allow, deny } = opt ?? {}; | ||
const { allow, deny, only } = opt ?? {}; | ||
const { hash, href, pathname, protocol, search } = new URL(url); | ||
@@ -280,4 +281,8 @@ const scheme = protocol.replace(/:$/, ''); | ||
]); | ||
if (Array.isArray(allow) && allow.length) { | ||
const items = Object.values(allow); | ||
if (Array.isArray(only) && only.length) { | ||
const schemes = super.get(); | ||
for (const item of schemes) { | ||
schemeMap.set(item, false); | ||
} | ||
const items = Object.values(only); | ||
for (let item of items) { | ||
@@ -291,13 +296,25 @@ if (isString(item)) { | ||
} | ||
} | ||
if (Array.isArray(deny) && deny.length) { | ||
const items = Object.values(deny); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (item) { | ||
schemeMap.set(item, false); | ||
} else { | ||
if (Array.isArray(allow) && allow.length) { | ||
const items = Object.values(allow); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (!REG_SCRIPT.test(item)) { | ||
schemeMap.set(item, true); | ||
} | ||
} | ||
} | ||
} | ||
if (Array.isArray(deny) && deny.length) { | ||
const items = Object.values(deny); | ||
for (let item of items) { | ||
if (isString(item)) { | ||
item = item.trim(); | ||
if (item) { | ||
schemeMap.set(item, false); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
@@ -394,2 +411,75 @@ let bool; | ||
} | ||
/** | ||
* object with extended props based on URL API | ||
* | ||
* @typedef {object} ParsedURL | ||
* @property {string} input - URL input | ||
* @property {boolean} valid - is valid URI | ||
* @property {object} data - parsed result of data URL, `null`able | ||
* @property {string} data.mime - MIME type | ||
* @property {boolean} data.base64 - `true` if base64 encoded | ||
* @property {string} data.data - data part of the data URL | ||
* @property {string} href - same as URL API | ||
* @property {string} origin - same as URL API | ||
* @property {string} protocol - same as URL API | ||
* @property {string} username - same as URL API | ||
* @property {string} password - same as URL API | ||
* @property {string} host - same as URL API | ||
* @property {string} hostname - same as URL API | ||
* @property {string} port - same as URL API | ||
* @property {string} pathname - same as URL API | ||
* @property {string} search - same as URL API | ||
* @property {object} searchParams - same as URL API | ||
* @property {string} hash - same as URL API | ||
*/ | ||
/** | ||
* parse sanitized URL | ||
* | ||
* @param {string} url - URL input | ||
* @returns {ParsedURL} - result with extended props based on URL API | ||
*/ | ||
parse(url) { | ||
if (!isString(url)) { | ||
throw new TypeError(`Expected String but got ${getType(url)}.`); | ||
} | ||
const sanitizedUrl = this.sanitize(url, { | ||
allow: ['data', 'file'] | ||
}); | ||
const parsedUrl = new Map([ | ||
['input', url] | ||
]); | ||
if (sanitizedUrl) { | ||
const urlObj = new URL(sanitizedUrl); | ||
const { pathname, protocol } = urlObj; | ||
const schemeParts = protocol.replace(/:$/, '').split('+'); | ||
parsedUrl.set('valid', true); | ||
if (schemeParts.includes('data')) { | ||
const dataUrl = new Map(); | ||
const [head, ...body] = pathname.split(','); | ||
const data = `${body.join(',')}`; | ||
const mediaType = head.split(';'); | ||
const isBase64 = mediaType[mediaType.length - 1] === 'base64'; | ||
if (isBase64) { | ||
mediaType.pop(); | ||
} | ||
dataUrl.set('mime', mediaType.join(';')); | ||
dataUrl.set('base64', isBase64); | ||
dataUrl.set('data', data); | ||
parsedUrl.set('data', Object.fromEntries(dataUrl)); | ||
} else { | ||
parsedUrl.set('data', null); | ||
} | ||
for (const key in urlObj) { | ||
const value = urlObj[key]; | ||
if (typeof value !== 'function') { | ||
parsedUrl.set(key, value); | ||
} | ||
} | ||
} else { | ||
parsedUrl.set('valid', false); | ||
} | ||
return Object.fromEntries(parsedUrl); | ||
} | ||
}; | ||
@@ -429,3 +519,4 @@ | ||
allow: [], | ||
deny: [] | ||
deny: [], | ||
only: [] | ||
}); | ||
@@ -445,2 +536,21 @@ | ||
/** | ||
* parse URL sync | ||
* | ||
* @param {string} url - URL input | ||
* @returns {ParsedURL} - result with extended props based on URL API | ||
*/ | ||
const parseUrl = url => urlSanitizer.parse(url); | ||
/** | ||
* parse URL async | ||
* | ||
* @param {string} url - URL input | ||
* @returns {Promise.<ParsedURL>} - result with extended props based on URL API | ||
*/ | ||
export const parseURL = async url => { | ||
const res = await parseUrl(url); | ||
return res; | ||
}; | ||
/* export instance and aliases */ | ||
@@ -450,3 +560,4 @@ export { | ||
isUri as isURISync, | ||
parseUrl as parseURLSync, | ||
sanitizeUrl as sanitizeURLSync | ||
}; |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
140990
2204
324