Accept browser https origins when TLS terminates before an HTTP proxy hop Honor forwarded external host while rejecting mismatched origins Add tests for proxy and host matching behavior
140 lines
4.6 KiB
JavaScript
140 lines
4.6 KiB
JavaScript
export const createRequestSecurityRuntime = (deps) => {
|
|
const { readSettingsFromDiskMigrated } = deps;
|
|
// Origins of packaged (non-browser) clients whose WebView origin never
|
|
// matches the server host: the desktop shell, the iOS Capacitor WebView
|
|
// (capacitor://localhost), and the Android Capacitor WebView, which uses
|
|
// androidScheme 'https' and therefore reports 'https://localhost'. Missing
|
|
// the Android origin 403'd every WebSocket upgrade from the Android app
|
|
// (message stream, terminal, dictation) while SSE kept working.
|
|
const packagedClientOrigins = new Set([
|
|
'openchamber-ui://app',
|
|
'capacitor://localhost',
|
|
'https://localhost',
|
|
]);
|
|
|
|
const getUiSessionTokenFromRequest = (req) => {
|
|
const cookieHeader = req?.headers?.cookie;
|
|
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
return null;
|
|
}
|
|
const segments = cookieHeader.split(';');
|
|
for (const segment of segments) {
|
|
const [rawName, ...rest] = segment.split('=');
|
|
const name = rawName?.trim();
|
|
if (!name) continue;
|
|
if (name !== 'oc_ui_session') continue;
|
|
const value = rest.join('=').trim();
|
|
try {
|
|
return decodeURIComponent(value || '');
|
|
} catch {
|
|
return value || null;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const rejectWebSocketUpgrade = (socket, statusCode, reason) => {
|
|
if (!socket || socket.destroyed) {
|
|
return;
|
|
}
|
|
|
|
const message = typeof reason === 'string' && reason.trim().length > 0 ? reason.trim() : 'Bad Request';
|
|
const body = Buffer.from(message, 'utf8');
|
|
const statusText = {
|
|
400: 'Bad Request',
|
|
401: 'Unauthorized',
|
|
403: 'Forbidden',
|
|
404: 'Not Found',
|
|
500: 'Internal Server Error',
|
|
}[statusCode] || 'Bad Request';
|
|
|
|
try {
|
|
socket.write(
|
|
`HTTP/1.1 ${statusCode} ${statusText}\r\n` +
|
|
'Connection: close\r\n' +
|
|
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
`Content-Length: ${body.length}\r\n\r\n`
|
|
);
|
|
socket.write(body);
|
|
} catch {
|
|
}
|
|
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
}
|
|
};
|
|
|
|
const getRequestOriginCandidates = async (req) => {
|
|
const origins = new Set();
|
|
const hosts = new Set();
|
|
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
|
|
? req.headers['x-forwarded-proto'].split(',')[0].trim().toLowerCase()
|
|
: '';
|
|
const protocol = forwardedProto || (req.socket?.encrypted ? 'https' : 'http');
|
|
|
|
const forwardedHost = typeof req.headers['x-forwarded-host'] === 'string'
|
|
? req.headers['x-forwarded-host'].split(',')[0].trim()
|
|
: '';
|
|
const host = forwardedHost || (typeof req.headers.host === 'string' ? req.headers.host.trim() : '');
|
|
|
|
if (host) {
|
|
hosts.add(host.toLowerCase());
|
|
origins.add(`${protocol}://${host}`);
|
|
const [hostname, port] = host.split(':');
|
|
const normalizedHost = typeof hostname === 'string' ? hostname.toLowerCase() : '';
|
|
const portSuffix = typeof port === 'string' && port.length > 0 ? `:${port}` : '';
|
|
if (normalizedHost === 'localhost') {
|
|
origins.add(`${protocol}://127.0.0.1${portSuffix}`);
|
|
origins.add(`${protocol}://[::1]${portSuffix}`);
|
|
} else if (normalizedHost === '127.0.0.1' || normalizedHost === '[::1]') {
|
|
origins.add(`${protocol}://localhost${portSuffix}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const settings = await readSettingsFromDiskMigrated();
|
|
if (typeof settings?.publicOrigin === 'string' && settings.publicOrigin.trim().length > 0) {
|
|
origins.add(new URL(settings.publicOrigin.trim()).origin);
|
|
}
|
|
} catch {
|
|
}
|
|
|
|
return { origins, hosts };
|
|
};
|
|
|
|
const isRequestOriginAllowed = async (req) => {
|
|
const originHeader = typeof req.headers.origin === 'string' ? req.headers.origin.trim() : '';
|
|
if (!originHeader) {
|
|
return false;
|
|
}
|
|
|
|
if (packagedClientOrigins.has(originHeader)) {
|
|
return true;
|
|
}
|
|
|
|
let origin;
|
|
try {
|
|
origin = new URL(originHeader);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const candidates = await getRequestOriginCandidates(req);
|
|
if (candidates.origins.has(origin.origin)) return true;
|
|
|
|
// TLS commonly ends at a cloud edge before an HTTP hop to OpenChamber.
|
|
// In that setup the browser's Origin is https while a generic reverse
|
|
// proxy reports the upstream request as http. The external host remains
|
|
// authoritative, so compare it directly instead of requiring the proxy to
|
|
// preserve the browser-facing protocol.
|
|
return candidates.hosts.has(origin.host.toLowerCase());
|
|
};
|
|
|
|
return {
|
|
getUiSessionTokenFromRequest,
|
|
rejectWebSocketUpgrade,
|
|
isRequestOriginAllowed,
|
|
};
|
|
};
|