mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(core): Improve Websocket connection setup for custom headers (#19242)
Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -120,12 +120,16 @@ describe('Push', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('handleRequest', () => {
|
describe('handleRequest', () => {
|
||||||
const req = mock<SSEPushRequest | WebSocketPushRequest>({ user });
|
|
||||||
const res = mock<PushResponse>();
|
|
||||||
const ws = mock<WebSocket>();
|
|
||||||
const backendNames = ['sse', 'websocket'] as const;
|
const backendNames = ['sse', 'websocket'] as const;
|
||||||
|
let req: ReturnType<typeof mock<SSEPushRequest | WebSocketPushRequest>>;
|
||||||
|
let res: ReturnType<typeof mock<PushResponse>>;
|
||||||
|
let ws: ReturnType<typeof mock<WebSocket>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
req = mock<SSEPushRequest | WebSocketPushRequest>({ user });
|
||||||
|
res = mock<PushResponse>();
|
||||||
|
ws = mock<WebSocket>();
|
||||||
|
|
||||||
res.status.mockReturnThis();
|
res.status.mockReturnThis();
|
||||||
|
|
||||||
req.headers.host = host;
|
req.headers.host = host;
|
||||||
@@ -162,12 +166,12 @@ describe('Push', () => {
|
|||||||
{
|
{
|
||||||
name: 'origin does not match x-forwarded-host',
|
name: 'origin does not match x-forwarded-host',
|
||||||
origin: `https://${host}`, // this is correct
|
origin: `https://${host}`, // this is correct
|
||||||
xForwardedHost: 'https://123example.com', // this is not
|
xForwardedHost: '123example.com', // this is not
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'origin does not match x-forwarded-host (subdomain)',
|
name: 'origin does not match x-forwarded-host (subdomain)',
|
||||||
origin: `https://${host}`, // this is correct
|
origin: `https://${host}`, // this is correct
|
||||||
xForwardedHost: `https://subdomain.${host}`, // this is not
|
xForwardedHost: `subdomain.${host}`, // this is not
|
||||||
},
|
},
|
||||||
])('$name', ({ origin, xForwardedHost }) => {
|
])('$name', ({ origin, xForwardedHost }) => {
|
||||||
req.headers.origin = origin;
|
req.headers.origin = origin;
|
||||||
@@ -225,6 +229,64 @@ describe('Push', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('port normalization bug reproduction', () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
name: 'HTTPS origin without port should match x-forwarded-host with default HTTPS port (443)',
|
||||||
|
origin: `https://${host}`,
|
||||||
|
xForwardedHost: `${host}:443`,
|
||||||
|
shouldPass: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HTTP origin without port should match x-forwarded-host with default HTTP port (80)',
|
||||||
|
origin: `http://${host}`,
|
||||||
|
xForwardedHost: `${host}:80`,
|
||||||
|
shouldPass: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'origin with explicit port should match x-forwarded-host with same port',
|
||||||
|
origin: `https://${host}:8080`,
|
||||||
|
xForwardedHost: `${host}:8080`,
|
||||||
|
shouldPass: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'origin without port should NOT match x-forwarded-host with non-default port',
|
||||||
|
origin: `https://${host}`,
|
||||||
|
xForwardedHost: `${host}:8080`,
|
||||||
|
shouldPass: false,
|
||||||
|
},
|
||||||
|
])('$name', ({ origin, xForwardedHost, shouldPass }) => {
|
||||||
|
// ARRANGE
|
||||||
|
req.headers.origin = origin;
|
||||||
|
req.headers['x-forwarded-host'] = xForwardedHost;
|
||||||
|
|
||||||
|
if (shouldPass) {
|
||||||
|
// Expected behavior: connection should be established
|
||||||
|
const emitSpy = jest.spyOn(push, 'emit');
|
||||||
|
const connection = backendName === 'sse' ? { req, res } : ws;
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection);
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef);
|
||||||
|
} else {
|
||||||
|
// Expected behavior: connection should be rejected
|
||||||
|
if (backendName === 'sse') {
|
||||||
|
expect(() => push.handleRequest(req, res)).toThrow(
|
||||||
|
new BadRequestError('Invalid origin!'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
expect(ws.send).toHaveBeenCalledWith('Invalid origin!');
|
||||||
|
expect(ws.close).toHaveBeenCalledWith(1008);
|
||||||
|
}
|
||||||
|
expect(backend.add).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should throw if pushRef is invalid', () => {
|
test('should throw if pushRef is invalid', () => {
|
||||||
req.query = { pushRef: '' };
|
req.query = { pushRef: '' };
|
||||||
|
|
||||||
@@ -261,6 +323,61 @@ describe('Push', () => {
|
|||||||
expect(backend.add).not.toHaveBeenCalled();
|
expect(backend.add).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('additional edge cases', () => {
|
||||||
|
test('should handle array x-forwarded-host header (use first)', () => {
|
||||||
|
req.headers['x-forwarded-host'] = [host, 'other-host.com'] as any;
|
||||||
|
req.headers.origin = `https://${host}`;
|
||||||
|
|
||||||
|
const emitSpy = jest.spyOn(push, 'emit');
|
||||||
|
const connection = backendName === 'sse' ? { req, res } : ws;
|
||||||
|
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
|
||||||
|
expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection);
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle Forwarded header with precedence over x-forwarded-*', () => {
|
||||||
|
req.headers.forwarded = `proto=https;host=${host}`;
|
||||||
|
req.headers['x-forwarded-host'] = 'wrong-host.com'; // Should be ignored
|
||||||
|
req.headers.origin = `https://${host}`;
|
||||||
|
|
||||||
|
const emitSpy = jest.spyOn(push, 'emit');
|
||||||
|
const connection = backendName === 'sse' ? { req, res } : ws;
|
||||||
|
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
|
||||||
|
expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection);
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should normalize default ports in Forwarded header', () => {
|
||||||
|
req.headers.forwarded = `proto=https;host=${host}:443`;
|
||||||
|
req.headers.origin = `https://${host}`;
|
||||||
|
|
||||||
|
const emitSpy = jest.spyOn(push, 'emit');
|
||||||
|
const connection = backendName === 'sse' ? { req, res } : ws;
|
||||||
|
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
|
||||||
|
expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection);
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle IPv6 addresses correctly', () => {
|
||||||
|
req.headers.origin = 'https://[::1]:443';
|
||||||
|
req.headers['x-forwarded-host'] = '[::1]:443';
|
||||||
|
|
||||||
|
const emitSpy = jest.spyOn(push, 'emit');
|
||||||
|
const connection = backendName === 'sse' ? { req, res } : ws;
|
||||||
|
|
||||||
|
push.handleRequest(req, res);
|
||||||
|
|
||||||
|
expect(backend.add).toHaveBeenCalledWith(pushRef, user.id, connection);
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('editorUiConnected', pushRef);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
435
packages/cli/src/push/__tests__/origin-validator.test.ts
Normal file
435
packages/cli/src/push/__tests__/origin-validator.test.ts
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
|
||||||
|
import { validateOriginHeaders } from '../origin-validator';
|
||||||
|
|
||||||
|
describe('validateOriginHeaders', () => {
|
||||||
|
const host = 'example.com';
|
||||||
|
|
||||||
|
describe('basic validation', () => {
|
||||||
|
test('should return invalid for missing origin', () => {
|
||||||
|
const result = validateOriginHeaders({});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toBe('Origin header is missing or malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return invalid for empty origin', () => {
|
||||||
|
const result = validateOriginHeaders({ origin: '' });
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toBe('Origin header is missing or malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return invalid for malformed origin', () => {
|
||||||
|
const result = validateOriginHeaders({ origin: 'invalid-origin' });
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toBe('Origin header is missing or malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate matching hosts with host header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo).toEqual({ protocol: 'https', host });
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
expect(result.expectedProtocol).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate matching hosts with x-forwarded-host header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject mismatched hosts', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': 'wrong-host.com',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toBe('Origin header does not match expected host');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('host normalization behavior', () => {
|
||||||
|
test('should normalize HTTPS default ports (443)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': `${host}:443`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should normalize HTTP default ports (80)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `http://${host}`,
|
||||||
|
'x-forwarded-host': `${host}:80`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should preserve non-default ports', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}:8080`,
|
||||||
|
'x-forwarded-host': `${host}:8080`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(`${host}:8080`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject mismatched ports', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}:8080`,
|
||||||
|
'x-forwarded-host': `${host}:9000`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle IPv6 addresses correctly', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'https://[::1]:443',
|
||||||
|
'x-forwarded-host': '[::1]:443',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe('::1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle IPv6 with non-default ports', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'http://[2001:db8::1]:8080',
|
||||||
|
'x-forwarded-host': '[2001:db8::1]:8080',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe('2001:db8::1:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle complex domain names', () => {
|
||||||
|
const complexHost = 'sub.domain.example-site.com';
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${complexHost}:443`,
|
||||||
|
'x-forwarded-host': `${complexHost}:443`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(complexHost);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invalid URL formats gracefully', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'https://example.com',
|
||||||
|
'x-forwarded-host': 'invalid[host',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.expectedHost).toBe('invalid[host');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('origin parsing behavior', () => {
|
||||||
|
test('should reject non-HTTP/HTTPS protocols', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'ftp://example.com',
|
||||||
|
host: 'example.com',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.error).toBe('Origin header is missing or malformed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject websocket protocols', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'ws://example.com',
|
||||||
|
host: 'example.com',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject chrome-extension protocols', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: 'chrome-extension://example.com',
|
||||||
|
host: 'example.com',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mixed case protocols', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `HTTPS://${host}`,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo?.protocol).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse HTTP origins correctly', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `http://${host}`,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo).toEqual({
|
||||||
|
protocol: 'http',
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse HTTPS origins correctly', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo).toEqual({
|
||||||
|
protocol: 'https',
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject malformed URLs that throw during parsing', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: '://malformed',
|
||||||
|
host: 'example.com',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('header precedence and processing', () => {
|
||||||
|
test('should handle Forwarded header with precedence over x-forwarded-*', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto=https;host=${host}`,
|
||||||
|
'x-forwarded-host': 'wrong-host.com', // Should be ignored
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse RFC 7239 Forwarded header with quoted values', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto="https";host="${host}"`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should parse Forwarded header with single quotes', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto='https';host='${host}'`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle multiple Forwarded entries (use first)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto=https;host=${host}, proto=http;host=other.com`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle Forwarded header with spaces around values', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: ` proto = https ; host = ${host} `,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore unknown parameters in Forwarded header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `for=192.0.2.60;proto=https;host=${host};by=proxy.com`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to X-Forwarded-* when Forwarded header incomplete', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: 'for=192.0.2.60', // Missing host and proto
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle array x-forwarded-host headers (use first)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': [host, 'other-host.com'],
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle array x-forwarded-proto headers (use first)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
'x-forwarded-proto': ['https', 'http'],
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle comma-separated x-forwarded-proto (use first)', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
'x-forwarded-proto': 'https, http',
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fallback to Host header when no proxy headers exist', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('protocol validation behavior', () => {
|
||||||
|
test('should handle valid protocols in Forwarded header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto=https;host=${host}`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore invalid protocols in Forwarded header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: `proto=ftp;host=${host}`, // Invalid protocol should be ignored
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore invalid protocols in X-Forwarded-Proto header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
'x-forwarded-proto': 'websocket', // Invalid protocol
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle comma-separated X-Forwarded-Proto with invalid first value', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
'x-forwarded-proto': 'ftp,https', // Invalid first, valid second
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedProtocol).toBe('https'); // Falls back to origin protocol
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle protocol change via Forwarded header', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `http://${host}`,
|
||||||
|
forwarded: `proto=http;host=${host}:80`,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
expect(result.expectedProtocol).toBe('http');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases and error handling', () => {
|
||||||
|
test('should handle missing host header when no proxy headers exist', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
// No host, x-forwarded-host, or forwarded headers
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.expectedHost).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty forwarded header gracefully', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: '',
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invalid forwarded header format', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
forwarded: 'invalid-format',
|
||||||
|
'x-forwarded-host': host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty x-forwarded-host array', () => {
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${host}`,
|
||||||
|
'x-forwarded-host': [],
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(host); // Falls back to host header
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle numeric IP addresses correctly', () => {
|
||||||
|
const ipHost = '192.168.1.100:3000';
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `https://${ipHost}`,
|
||||||
|
'x-forwarded-host': ipHost,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(ipHost);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle localhost correctly', () => {
|
||||||
|
const localhostHost = 'localhost:8080';
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: `http://${localhostHost}`,
|
||||||
|
'x-forwarded-host': localhostHost,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.expectedHost).toBe(localhostHost);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow origins with query parameters when host matches', () => {
|
||||||
|
const queryOrigin = `https://${host}?query=param#fragment`;
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: queryOrigin,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo?.host).toBe(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow origins with path components when host matches', () => {
|
||||||
|
const pathOrigin = `https://${host}/path/to/resource`;
|
||||||
|
const result = validateOriginHeaders({
|
||||||
|
origin: pathOrigin,
|
||||||
|
host,
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.originInfo?.host).toBe(host);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
|||||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||||
import { TypedEmitter } from '@/typed-emitter';
|
import { TypedEmitter } from '@/typed-emitter';
|
||||||
|
|
||||||
|
import { validateOriginHeaders } from './origin-validator';
|
||||||
import { PushConfig } from './push.config';
|
import { PushConfig } from './push.config';
|
||||||
import { SSEPush } from './sse.push';
|
import { SSEPush } from './sse.push';
|
||||||
import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
import type { OnPushMessage, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
||||||
@@ -111,24 +112,25 @@ export class Push extends TypedEmitter<PushEvents> {
|
|||||||
|
|
||||||
let connectionError = '';
|
let connectionError = '';
|
||||||
|
|
||||||
// Extract host domain from origin
|
|
||||||
const originHost = headers.origin?.replace(/^https?:\/\//, '');
|
|
||||||
|
|
||||||
if (!pushRef) {
|
if (!pushRef) {
|
||||||
connectionError = 'The query parameter "pushRef" is missing!';
|
connectionError = 'The query parameter "pushRef" is missing!';
|
||||||
} else if (!originHost) {
|
|
||||||
this.logger.warn('Origin header is missing');
|
|
||||||
|
|
||||||
connectionError = 'Invalid origin!';
|
|
||||||
} else if (inProduction) {
|
} else if (inProduction) {
|
||||||
const expectedHost =
|
const validation = validateOriginHeaders(headers);
|
||||||
typeof headers['x-forwarded-host'] === 'string'
|
if (!validation.isValid) {
|
||||||
? headers['x-forwarded-host']
|
|
||||||
: headers.host;
|
|
||||||
if (expectedHost !== originHost) {
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Origin header does NOT match the expected origin. (Origin: "${originHost}", Expected: "${expectedHost}")`,
|
'Origin header does NOT match the expected origin. ' +
|
||||||
{ headers: pick(headers, ['host', 'origin', 'x-forwarded-proto', 'x-forwarded-host']) },
|
`(Origin: "${headers.origin}" -> "${validation.originInfo?.host || 'N/A'}", ` +
|
||||||
|
`Expected: "${validation.rawExpectedHost}" -> "${validation.expectedHost}", ` +
|
||||||
|
`Protocol: "${validation.expectedProtocol}")`,
|
||||||
|
{
|
||||||
|
headers: pick(headers, [
|
||||||
|
'host',
|
||||||
|
'origin',
|
||||||
|
'x-forwarded-proto',
|
||||||
|
'x-forwarded-host',
|
||||||
|
'forwarded',
|
||||||
|
]),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
connectionError = 'Invalid origin!';
|
connectionError = 'Invalid origin!';
|
||||||
}
|
}
|
||||||
|
|||||||
236
packages/cli/src/push/origin-validator.ts
Normal file
236
packages/cli/src/push/origin-validator.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
export interface OriginValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
originInfo?: { protocol: 'http' | 'https'; host: string };
|
||||||
|
expectedHost?: string;
|
||||||
|
expectedProtocol?: 'http' | 'https';
|
||||||
|
rawExpectedHost?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates origin headers against expected host from proxy headers.
|
||||||
|
* Handles X-Forwarded-Host, X-Forwarded-Proto, and RFC 7239 Forwarded headers.
|
||||||
|
*
|
||||||
|
* @param headers HTTP request headers
|
||||||
|
* @returns Validation result with details about the origin check
|
||||||
|
*/
|
||||||
|
export function validateOriginHeaders(headers: Request['headers']): OriginValidationResult {
|
||||||
|
// Parse and normalize the origin using native URL class
|
||||||
|
const originInfo = parseOrigin(headers.origin ?? '');
|
||||||
|
|
||||||
|
if (!originInfo) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: 'Origin header is missing or malformed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract expected host from proxy headers using same precedence logic
|
||||||
|
let rawExpectedHost: string | undefined;
|
||||||
|
let expectedProtocol = originInfo.protocol;
|
||||||
|
|
||||||
|
// 1. Try RFC 7239 Forwarded header first
|
||||||
|
const forwarded = parseForwardedHeader(headers.forwarded ?? '');
|
||||||
|
if (forwarded?.host) {
|
||||||
|
rawExpectedHost = forwarded.host;
|
||||||
|
if (forwarded.proto) {
|
||||||
|
const validatedProto = validateProtocol(forwarded.proto);
|
||||||
|
if (validatedProto) {
|
||||||
|
expectedProtocol = validatedProto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Try X-Forwarded-Host
|
||||||
|
else {
|
||||||
|
const xForwardedHost = getFirstHeaderValue(headers['x-forwarded-host']);
|
||||||
|
if (xForwardedHost) {
|
||||||
|
rawExpectedHost = xForwardedHost;
|
||||||
|
const xForwardedProto = getFirstHeaderValue(headers['x-forwarded-proto']);
|
||||||
|
if (xForwardedProto) {
|
||||||
|
const validatedProto = validateProtocol(xForwardedProto.split(',')[0]?.trim());
|
||||||
|
if (validatedProto) {
|
||||||
|
expectedProtocol = validatedProto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. Fallback to Host header
|
||||||
|
else {
|
||||||
|
rawExpectedHost = headers.host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize expected host using the determined protocol
|
||||||
|
const normalizedExpectedHost = normalizeHost(rawExpectedHost ?? '', expectedProtocol);
|
||||||
|
|
||||||
|
const isValid = normalizedExpectedHost === originInfo.host;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid,
|
||||||
|
originInfo,
|
||||||
|
expectedHost: normalizedExpectedHost,
|
||||||
|
expectedProtocol,
|
||||||
|
rawExpectedHost,
|
||||||
|
error: isValid ? undefined : 'Origin header does not match expected host',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a host by removing default ports for the given protocol.
|
||||||
|
* Uses native URL class for robust parsing of hosts and ports.
|
||||||
|
*
|
||||||
|
* @param host The host string (e.g., "example.com:80", "example.com", "[::1]:8080")
|
||||||
|
* @param protocol The protocol ('http' or 'https')
|
||||||
|
* @returns The normalized host string with default ports removed
|
||||||
|
*/
|
||||||
|
function normalizeHost(host: string, protocol: 'http' | 'https'): string {
|
||||||
|
if (!host) return host;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use URL constructor to parse the host properly
|
||||||
|
// We need to prepend a protocol to make it a valid URL
|
||||||
|
const url = new URL(`${protocol}://${host}`);
|
||||||
|
|
||||||
|
// URL.host includes port, URL.hostname excludes port
|
||||||
|
// If the port is default for the protocol, URL.host will exclude it
|
||||||
|
// Otherwise, it will include it
|
||||||
|
const defaultPort = protocol === 'https' ? '443' : '80';
|
||||||
|
const actualPort = url.port || defaultPort;
|
||||||
|
|
||||||
|
// If the port matches the default, return hostname only (strip IPv6 brackets)
|
||||||
|
if (actualPort === defaultPort) {
|
||||||
|
return stripIPv6Brackets(url.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return hostname:port for non-default ports (strip IPv6 brackets)
|
||||||
|
return stripIPv6Brackets(url.host);
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, fall back to original host
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips brackets from IPv6 addresses for consistency.
|
||||||
|
* IPv6 brackets are URL syntax and should be removed when comparing hosts.
|
||||||
|
*
|
||||||
|
* @param hostname The hostname that may contain IPv6 brackets (e.g., "[::1]" or "[::1]:8080")
|
||||||
|
* @returns Hostname with IPv6 brackets removed if present (e.g., "::1" or "::1:8080")
|
||||||
|
*/
|
||||||
|
function stripIPv6Brackets(hostname: string): string {
|
||||||
|
// Handle IPv6 with port: [::1]:8080 -> ::1:8080
|
||||||
|
if (hostname.startsWith('[') && hostname.includes(']:')) {
|
||||||
|
const closingBracket = hostname.indexOf(']:');
|
||||||
|
const ipv6 = hostname.slice(1, closingBracket); // Extract ::1
|
||||||
|
const port = hostname.slice(closingBracket + 2); // Extract 8080
|
||||||
|
return `${ipv6}:${port}`;
|
||||||
|
}
|
||||||
|
// Handle IPv6 without port: [::1] -> ::1
|
||||||
|
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||||
|
return hostname.slice(1, -1);
|
||||||
|
}
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts the first value from a header that could be string or string[].
|
||||||
|
*
|
||||||
|
* @param header The header value (string or string[])
|
||||||
|
* @returns First header value or undefined
|
||||||
|
*/
|
||||||
|
function getFirstHeaderValue(header: string | string[] | undefined): string | undefined {
|
||||||
|
if (!header) return undefined;
|
||||||
|
if (typeof header === 'string') return header;
|
||||||
|
return header[0]; // Take first value from array
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and normalizes a protocol value to ensure it's http or https.
|
||||||
|
*
|
||||||
|
* @param proto Protocol value from headers
|
||||||
|
* @returns 'http' or 'https' if valid, undefined if invalid
|
||||||
|
*/
|
||||||
|
function validateProtocol(proto: string | undefined): 'http' | 'https' | undefined {
|
||||||
|
if (!proto) return undefined;
|
||||||
|
const normalized = proto.toLowerCase().trim();
|
||||||
|
return normalized === 'http' || normalized === 'https' ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the RFC 7239 Forwarded header to extract host and proto values.
|
||||||
|
*
|
||||||
|
* @param forwardedHeader The Forwarded header value (e.g., "for=192.0.2.60;proto=http;host=example.com")
|
||||||
|
* @returns Object with host and proto, or null if parsing fails
|
||||||
|
*/
|
||||||
|
function parseForwardedHeader(forwardedHeader: string): { host?: string; proto?: string } | null {
|
||||||
|
if (!forwardedHeader || typeof forwardedHeader !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse the first forwarded entry (comma-separated list)
|
||||||
|
const firstEntry = forwardedHeader.split(',')[0]?.trim();
|
||||||
|
if (!firstEntry) return null;
|
||||||
|
|
||||||
|
const result: { host?: string; proto?: string } = {};
|
||||||
|
|
||||||
|
// Parse semicolon-separated key=value pairs
|
||||||
|
const pairs = firstEntry.split(';');
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const [key, value] = pair.split('=', 2);
|
||||||
|
if (!key || !value) continue;
|
||||||
|
|
||||||
|
const cleanKey = key.trim().toLowerCase();
|
||||||
|
const cleanValue = value.trim().replace(/^["']|["']$/g, ''); // Remove quotes
|
||||||
|
|
||||||
|
if (cleanKey === 'host') {
|
||||||
|
result.host = cleanValue;
|
||||||
|
} else if (cleanKey === 'proto') {
|
||||||
|
result.proto = cleanValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts protocol and normalized host from an origin URL using native URL class.
|
||||||
|
*
|
||||||
|
* @param origin The origin URL (e.g., "https://example.com", "http://localhost:3000")
|
||||||
|
* @returns Object with protocol and normalized host, or null if invalid
|
||||||
|
*/
|
||||||
|
function parseOrigin(origin: string): { protocol: 'http' | 'https'; host: string } | null {
|
||||||
|
if (!origin || typeof origin !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const protocol = url.protocol.toLowerCase();
|
||||||
|
|
||||||
|
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolName = protocol === 'https:' ? 'https' : 'http';
|
||||||
|
|
||||||
|
// Use the same normalization logic - remove default ports and IPv6 brackets
|
||||||
|
const defaultPort = protocolName === 'https' ? '443' : '80';
|
||||||
|
const actualPort = url.port || defaultPort;
|
||||||
|
|
||||||
|
const rawHost = actualPort === defaultPort ? url.hostname : url.host;
|
||||||
|
const normalizedHost = stripIPv6Brackets(rawHost);
|
||||||
|
|
||||||
|
return {
|
||||||
|
protocol: protocolName,
|
||||||
|
host: normalizedHost,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Invalid URL format
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user