Files
RustProxy/tools/checks/admin_public_origin_0314_sim.mjs
T
2026-10-11 13:56:30 +02:00

197 lines
11 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 include_public_origin_host(&mut self)', '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);
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
// The validated public panel hostname is derived at runtime.
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('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('pin: normal GET', {},PIN,'pass');
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();}