Files
safetynet/apps/network-broker/tls-authority.cjs
T

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};