211 lines
13 KiB
JavaScript
211 lines
13 KiB
JavaScript
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
|
|
// Real-source anchors guard against accidental model/source drift. This is NOT
|
|
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import {fileURLToPath} from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
|
|
const source = {
|
|
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
|
|
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
|
|
};
|
|
for (const [part, tokens] of Object.entries({
|
|
settings: ['pub public_origin: Option<String>', 'fn normalize_public_admin_origin(public_origin: &str)', 'fn include_public_origin_host(&mut self)', 'fn explicitly_allowed_panel_host(&self, host: &str)', 'self.public_origin != active.public_origin'],
|
|
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
|
|
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
|
|
forwarding: ['self.trust_proto && self.trusted(ip)'],
|
|
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
|
|
|
|
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
|
|
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
|
|
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
|
|
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
|
|
automation:false};
|
|
const normal = x => x.toLowerCase();
|
|
function authority(value) {
|
|
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
|
|
try {
|
|
const parsed = new URL(`https://${value}/`);
|
|
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
|
|
const host = parsed.hostname.toLowerCase();
|
|
return {host, port:parsed.port ? Number(parsed.port) : null};
|
|
} catch {return null;}
|
|
}
|
|
function sameAuthority(a,b,scheme) {
|
|
const left=authority(a), right=authority(b);
|
|
if (!left || !right) return false;
|
|
const defaultPort = scheme === 'https' ? 443 : 80;
|
|
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
|
|
}
|
|
function hostAllowed(value, tls, cfg) {
|
|
const actual=authority(value);
|
|
if (!actual) return false;
|
|
const port=actual.port ?? (tls ? 443 : 80);
|
|
// Effective runtime/desired allowlist also includes the public origin host;
|
|
// only extra names are persisted in allowed_hosts.
|
|
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
|
|
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|
|
|| cfg.allowedHosts.includes(actual.host)
|
|
|| actual.host===pinnedHost;
|
|
}
|
|
const map = raw => {
|
|
const headers=new Map();
|
|
for (let i=0;i<raw.length;i+=2) {
|
|
const name=normal(raw[i]);
|
|
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
|
|
}
|
|
return headers;
|
|
};
|
|
const all = (headers,name) => headers.get(name)||[];
|
|
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
|
|
function protoCompatible(headers) {
|
|
let hops=0;
|
|
for (const value of all(headers,'x-forwarded-proto')) {
|
|
for (const part of value.split(',')) {
|
|
hops++;
|
|
const scheme=part.trim();
|
|
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
function originMatches(value,scheme,external) {
|
|
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
|
|
try {
|
|
const uri=new URL(value);
|
|
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
|
|
return sameAuthority(uri.host, external, scheme);
|
|
} catch {return false;}
|
|
}
|
|
function guard(headers,cfg) {
|
|
const rawHost=single(headers,'host');
|
|
if (!rawHost) return {status:403,reason:'invalid_host'};
|
|
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
|
|
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
|
|
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
|
|
let pinned;
|
|
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
|
|
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
|
|
return {status:403,reason:'pinned_origin_mismatch'};
|
|
const xfh=all(headers,'x-forwarded-host');
|
|
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
|
|
return {status:403,reason:'pinned_origin_mismatch'};
|
|
scheme='https'; external=pinned.host;
|
|
} else {
|
|
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
|
|
const proto=single(headers,'x-forwarded-proto');
|
|
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
|
|
scheme=proto;
|
|
}
|
|
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
|
|
external=single(headers,'x-forwarded-host');
|
|
if (!hostAllowed(external,scheme==='https',cfg))
|
|
return {status:403,reason:'invalid_host'};
|
|
}
|
|
const originValues=all(headers,'origin');
|
|
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
|
|
return {status:403,reason:'origin_mismatch'};
|
|
if (all(headers,'sec-fetch-site').includes('cross-site'))
|
|
return {status:403,reason:'cross_site_fetch'};
|
|
return {status:200,reason:'pass',scheme};
|
|
}
|
|
const cases=[];
|
|
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
|
|
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
|
|
add('legacy normal same-origin GET', {},{},'pass');
|
|
add('no pin and no DNS allowlist rejects proxied host', {},{allowedHosts:[]},'invalid_host');
|
|
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
|
|
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
|
|
add('legacy explicit DNS host without pin', {'host':'other.example.test','x-forwarded-host':'other.example.test'},
|
|
{allowedHosts:['other.example.test']}, 'pass');
|
|
add('legacy extra host without pin still denies unknown', {'host':'evil.example.test','x-forwarded-host':'evil.example.test'},
|
|
{allowedHosts:['other.example.test']}, 'invalid_host');
|
|
add('pin: normal GET, persisted allowed_hosts empty', {},PIN,'pass');
|
|
add('pin: public Host implicitly allowed', {'host':'panel.example.test'},PIN,'pass');
|
|
add('pin: unknown Host still rejected', {'host':'unknown.example.test'},PIN,'pinned_origin_mismatch');
|
|
add('pin: old 0.31.4 explicit hostname remains compatible', {}, {...PIN,allowedHosts:['panel.example.test']},'pass');
|
|
add('pin: additional DNS host accepted only with matching forwarded public Host',
|
|
{'host':'other.example.test'}, {...PIN,allowedHosts:['other.example.test']},'pass');
|
|
add('pin: changing public origin does not allow previous hostname',
|
|
{'host':'panel.example.test','x-forwarded-host':'new.example.test'},
|
|
{...PIN,publicOrigin:'https://new.example.test'},'pinned_origin_mismatch');
|
|
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
|
|
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
|
|
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
|
|
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
|
|
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
|
|
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
|
|
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
|
|
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
|
|
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
|
|
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
|
|
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
|
|
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
|
|
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
|
|
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
|
|
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
|
|
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
|
|
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
|
|
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
|
|
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
|
|
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
|
|
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
|
|
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
|
|
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
|
|
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
|
|
|
|
function encodedHeaders(patch) {
|
|
const merged={...defaults,...patch};
|
|
const result={};
|
|
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
|
|
return result;
|
|
}
|
|
const pending=[];
|
|
const server=http.createServer((req,res)=>{
|
|
const item=pending.shift();
|
|
if (!item) return res.writeHead(500).end('no scenario');
|
|
const result=guard(map(req.rawHeaders),{...context,...item.opts});
|
|
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
|
|
});
|
|
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
async function request(item) {
|
|
pending.push(item);
|
|
return await new Promise((resolve,reject)=>{
|
|
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
|
|
agent:false,headers:encodedHeaders(item.patch)},res=>{
|
|
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
|
|
res.on('end',()=>resolve(JSON.parse(data)));
|
|
});
|
|
req.on('error',reject);req.end();
|
|
});
|
|
}
|
|
try {
|
|
for (const item of cases) {
|
|
const result=await request(item);
|
|
assert.equal(result.reason,item.expected,item.name);
|
|
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
|
|
}
|
|
const repeats=[];
|
|
for (const configured of [false,true]) {
|
|
for (let attempt=1;attempt<=12;attempt++) {
|
|
const malformed=attempt%4===0;
|
|
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
|
|
opts:configured? PIN : {}});
|
|
assert.equal(result.status,configured?200:(malformed?403:200));
|
|
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
|
|
}
|
|
}
|
|
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
|
|
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
|
|
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
|
|
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
|
|
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
|
|
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
|
|
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
|
|
} finally {server.close();}
|