fix(proxy): implement JS-layer SNI for per-TLD wildcard certs and fix chunked encoding
CI / Build & Test (push) Successful in 3m27s

Replace the bare-https HTTPS proxy with a bare-tcp server that implements
SNI entirely in JavaScript. A pure-JS TLS ClientHello parser extracts the
SNI hostname from each incoming connection, derives the wildcard parent
domain by stripping the leftmost label, and selects (or generates on demand)
the correct wildcard cert via certificate-authority.getOrCreateWildcardCert().
This fixes ERR_SSL_SERVER_CERT_BAD_FORMAT for custom TLDs and supports
hostnames of any depth (e.g. i.love.hole.sail → cert *.love.hole.sail).

Also fixes ERR_INVALID_CHUNKED_ENCODING by stripping hop-by-hop headers
(Transfer-Encoding, Connection, etc.) from proxied responses — bare-http1
decodes chunked bodies internally so forwarding the header caused Chrome
to misinterpret the already-decoded body bytes.

- https-proxy.js: rewrite using bare-tcp + JS SNI peek + bare-tls per conn
- certificate-authority.js: add getOrCreateWildcardCert(parentDomain)
- host.js: remove refreshProxyCert/getActiveBaseDomains (no longer needed)
- background.js: PAC dnsDomainIs clauses already match any depth correctly
- dashboard.html: update vhost hint text to show deep hostnames are supported
- docs: update ARCHITECTURE, SECURITY, NATIVE-HOST; add VIRTUAL-HOSTS.md
This commit is contained in:
Raven Scott
2026-02-28 21:20:20 -05:00
parent c51717699d
commit 2cb32f9537
+24 -7
View File
@@ -266,16 +266,28 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
proxyPath = withoutHost || '/';
}
// Hop-by-hop headers must not be forwarded — bare-http1 handles these
// transparently. Forwarding Transfer-Encoding: chunked would cause
// ERR_INVALID_CHUNKED_ENCODING because the HTTP library already decodes
// the chunked body before emitting 'data' events.
const HOP_BY_HOP = new Set([
'transfer-encoding', 'connection', 'keep-alive', 'proxy-connection',
'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'upgrade'
]);
const reqHeaders = {};
for (const k of Object.keys(req.headers)) {
if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = req.headers[k];
}
reqHeaders.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : '');
const opts = {
host: targetHost,
port: targetPort,
path: proxyPath,
method: req.method || 'GET',
headers: { ...req.headers }
headers: reqHeaders
};
delete opts.headers['proxy-connection'];
delete opts.headers['proxy-authorization'];
opts.headers.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : '');
let timedOut = false;
const timeoutId = setTimeout(() => {
@@ -292,9 +304,14 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
clearTimeout(timeoutId);
if (timedOut) return;
res.statusCode = proxyRes.statusCode;
const headers = proxyRes.headers;
for (const k in headers) {
try { res.setHeader(k, headers[k]); } catch (_) {}
// Strip hop-by-hop headers from the backend response before forwarding
// to the browser. In particular, Transfer-Encoding must be removed because
// bare-http1 decodes chunked bodies internally — the 'data' events already
// contain the raw payload bytes, not chunked-encoded wire bytes.
for (const k of Object.keys(proxyRes.headers)) {
if (!HOP_BY_HOP.has(k.toLowerCase())) {
try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {}
}
}
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk));
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());