Files
TREK/server/tests/unit/middleware/globalMiddleware.cors.test.ts
T
Maurice bd5a6cc748 fix(cors): the instance's own address no longer trips ALLOWED_ORIGINS
With ALLOWED_ORIGINS set, every request carrying an Origin that was not on
the list was failed with an unhandled error, which the exception filter
answered with a 500. A browser sends Origin on every POST and WebSocket
upgrade, including same-origin ones, so opening TREK under any address the
list did not name (the LAN IP, a second hostname, http instead of https)
broke the login with "Internal Server Error" while another browser that
used the listed address worked.

A request whose Origin is the host it was sent to is now treated as
same-origin and always passes, for REST and the /ws upgrade alike. A
foreign origin is still refused, now with a 403 "Not allowed by CORS" and a
warning in the log naming the origin to add.

Closes #2543
2026-09-29 21:16:26 +02:00

106 lines
4.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import express, { type NextFunction, type Request, type Response } from 'express';
import request from 'supertest';
import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware';
import { readEnv } from '../../../src/app-config';
import { isSameHostOrigin } from '../../../src/nest/common/same-origin';
/**
* An app with ALLOWED_ORIGINS set, and the error mapping TrekExceptionFilter
* applies to a plain error (statusCode, else 500).
*/
function appWithAllowlist(corsOrigins: string[] | null) {
const app = express();
applyGlobalMiddleware(app, { http: { ...readEnv().http, corsOrigins } });
app.post('/api/auth/login', (_req, res) => res.json({ ok: true }));
app.use((err: { statusCode?: number; message?: string }, _req: Request, res: Response, _next: NextFunction) => {
const status = err.statusCode || 500;
res.status(status).json({ error: status < 500 ? err.message : 'Internal server error' });
});
return app;
}
describe('CORS with ALLOWED_ORIGINS set (#2543)', () => {
const app = appWithAllowlist(['https://trek.example.com']);
it('lets a request through whose Origin is the host it was sent to', async () => {
// The instance reached under another name than the one in ALLOWED_ORIGINS
// (its LAN address, http instead of https): the login POST carries the page's
// own Origin, and refusing it answered the login form with a 500.
const res = await request(app)
.post('/api/auth/login')
.set('Host', '192.168.1.20:30080')
.set('Origin', 'http://192.168.1.20:30080')
.send({});
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('http://192.168.1.20:30080');
});
it('still lets the listed origin through, with credentials', async () => {
const res = await request(app).post('/api/auth/login').set('Origin', 'https://trek.example.com').send({});
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('https://trek.example.com');
expect(res.headers['access-control-allow-credentials']).toBe('true');
});
it('still lets a request without Origin through', async () => {
const res = await request(app).post('/api/auth/login').send({});
expect(res.status).toBe(200);
});
it('refuses a foreign origin with a 403 that names the cause, not a 500', async () => {
const res = await request(app)
.post('/api/auth/login')
.set('Host', 'trek.example.com')
.set('Origin', 'https://evil.example')
.send({});
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Not allowed by CORS' });
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('refuses Origin: null', async () => {
const res = await request(app).post('/api/auth/login').set('Origin', 'null').send({});
expect(res.status).toBe(403);
});
});
describe('CORS without ALLOWED_ORIGINS', () => {
it('passes everything through as before (the test env is not production)', async () => {
const res = await request(appWithAllowlist(null))
.post('/api/auth/login')
.set('Origin', 'https://anything.example')
.send({});
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('https://anything.example');
});
});
describe('isSameHostOrigin', () => {
it('matches host and port', () => {
expect(isSameHostOrigin('http://192.168.1.20:30080', '192.168.1.20:30080')).toBe(true);
expect(isSameHostOrigin('https://trek.example.com', 'trek.example.com')).toBe(true);
expect(isSameHostOrigin('https://Trek.Example.com', 'TREK.example.com')).toBe(true);
});
it('drops the default port of the Origin\'s scheme from the Host', () => {
expect(isSameHostOrigin('https://trek.example.com', 'trek.example.com:443')).toBe(true);
expect(isSameHostOrigin('http://trek.example.com', 'trek.example.com:80')).toBe(true);
});
it('does not match another host or another port', () => {
expect(isSameHostOrigin('https://evil.example', 'trek.example.com')).toBe(false);
expect(isSameHostOrigin('http://192.168.1.20:3000', '192.168.1.20:30080')).toBe(false);
expect(isSameHostOrigin('https://trek.example.com.evil.example', 'trek.example.com')).toBe(false);
});
it('refuses anything that is not an http(s) origin or a plain host', () => {
expect(isSameHostOrigin('null', 'trek.example.com')).toBe(false);
expect(isSameHostOrigin(undefined, 'trek.example.com')).toBe(false);
expect(isSameHostOrigin('https://trek.example.com', undefined)).toBe(false);
expect(isSameHostOrigin('file://trek.example.com', 'trek.example.com')).toBe(false);
expect(isSameHostOrigin('https://evil.example', 'x@evil.example')).toBe(false);
expect(isSameHostOrigin('https://evil.example', 'evil.example/path')).toBe(false);
});
});