77 lines
3.6 KiB
JavaScript
77 lines
3.6 KiB
JavaScript
'use strict';
|
|
const {isIP}=require('node:net');
|
|
const bindings=new WeakSet();
|
|
class AuthorityError extends Error {
|
|
constructor(code){super(code);this.name='AuthorityError';this.code=code;}
|
|
}
|
|
const deny=code=>{throw new AuthorityError(code);};
|
|
|
|
function parseAuthority(value,{requirePort=false}={}) {
|
|
if(typeof value!=='string'||!value||value.length>320||/[^\x21-\x7e]|[@/\\?#%]/.test(value))deny('invalid-authority');
|
|
let hostname,portText;
|
|
if(value.startsWith('[')) {
|
|
const match=/^\[([^\]]+)\](?::([^:]+))?$/.exec(value);
|
|
if(!match||isIP(match[1])!==6)deny('invalid-authority');
|
|
hostname=new URL(`https://[${match[1]}]/`).hostname.slice(1,-1);
|
|
portText=match[2];
|
|
} else {
|
|
const match=/^([^:]+)(?::([^:]+))?$/.exec(value);
|
|
if(!match)deny('invalid-authority');
|
|
hostname=match[1].toLowerCase();portText=match[2];
|
|
if(isIP(hostname)!==4) {
|
|
// Forbid URL-parser legacy numerical IPv4 aliases (127.1, octal,
|
|
// decimal and hex). DNS labels remain explicit ASCII / punycode.
|
|
if(/^[0-9.]+$/.test(hostname)||hostname.split('.').some(label=>/^0x[0-9a-f]+$/i.test(label)))deny('invalid-authority');
|
|
if(hostname.length>253||hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))deny('invalid-authority');
|
|
}
|
|
}
|
|
if(requirePort&&portText===undefined)deny('missing-port');
|
|
if(portText!==undefined&&!/^[1-9][0-9]{0,4}$/.test(portText))deny('invalid-port');
|
|
const port=portText===undefined?443:Number(portText);
|
|
if(port>65535)deny('invalid-port');
|
|
return Object.freeze({hostname,port,authority:`${isIP(hostname)===6?'['+hostname+']':hostname}:${port}`});
|
|
}
|
|
|
|
/** Caller authenticates CONNECT before creating this immutable binding.
|
|
* Parsing does not authenticate a user, authorize a destination, terminate
|
|
* TLS, validate upstream certificates or constrain process/OS egress.
|
|
*/
|
|
function bindConnectAuthority(value) {
|
|
const binding=parseAuthority(value,{requirePort:true});bindings.add(binding);return binding;
|
|
}
|
|
|
|
function verifyInnerAuthority(binding,{rawHeaders,url,servername}) {
|
|
if(!binding||!bindings.has(binding))deny('invalid-binding');
|
|
const matches=value=>parseAuthority(value).authority===binding.authority;
|
|
if(!Array.isArray(rawHeaders)||rawHeaders.length%2)deny('invalid-headers');
|
|
let host;
|
|
for(let index=0;index<rawHeaders.length;index+=2) {
|
|
if(typeof rawHeaders[index]!=='string'||typeof rawHeaders[index+1]!=='string')deny('invalid-headers');
|
|
if(rawHeaders[index].toLowerCase()==='host') {
|
|
if(host!==undefined)deny('duplicate-host');
|
|
host=rawHeaders[index+1];
|
|
}
|
|
}
|
|
if(host===undefined)deny('missing-host');
|
|
if(!matches(host))deny('authority-mismatch');
|
|
if(typeof url!=='string'||!url||url.includes('\\')||[...url].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)===127))deny('invalid-target');
|
|
if(url.startsWith('/')) {
|
|
if(url.startsWith('//')||url.includes('#'))deny('invalid-target');
|
|
} else {
|
|
const match=/^https:\/\/([^/;?#]+)(?:\/[^#]*)?$/.exec(url);
|
|
if(!match)deny('invalid-target');
|
|
if(!matches(match[1]))deny('authority-mismatch');
|
|
}
|
|
if(servername===undefined||servername==='') {
|
|
if(!isIP(binding.hostname))deny('missing-sni');
|
|
} else {
|
|
// TLS SNI is a hostname, never an authority carrying a port.
|
|
const sni=isIP(servername)===6?`[${servername}]`:servername;
|
|
const parsed=parseAuthority(sni);
|
|
if(sni.includes(':')&&isIP(servername)!==6)deny('invalid-sni');
|
|
if(parsed.hostname!==binding.hostname)deny('sni-mismatch');
|
|
}
|
|
return binding;
|
|
}
|
|
module.exports={AuthorityError,parseAuthority,bindConnectAuthority,verifyInnerAuthority};
|