Files
dashy/tests/server/cors-proxy.test.js

264 lines
9.9 KiB
JavaScript

// @vitest-environment node
import http from 'http';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
describe, it, expect, afterEach, beforeAll, afterAll, vi,
} from 'vitest';
import request from 'supertest';
// Isolate from the repo's conf.yml so test behaviour doesn't depend on which
// auth method (if any) the developer has configured locally.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-cors-test-'));
process.env.USER_DATA_DIR = tmpDir;
const app = require('../../services/app');
const { substituteEnv } = require('../../services/cors-proxy');
describe('CORS proxy', () => {
it('rejects missing Target-URL', async () => {
const res = await request(app).get('/cors-proxy');
expect(res.status).toBe(400);
expect(res.body.error).toContain('Target-URL');
});
it('rejects invalid URL', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'not-a-url');
expect(res.status).toBe(400);
});
it('rejects file:// scheme', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'file:///etc/passwd');
expect(res.status).toBe(400);
expect(res.body.error).toContain('http');
});
it('rejects ftp:// scheme', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'ftp://x.com');
expect(res.status).toBe(400);
});
it('blocks cloud metadata IPv4', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://169.254.169.254/');
expect(res.status).toBe(403);
expect(res.body.error).toContain('blocked');
});
it('blocks cloud metadata decimal bypass', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://2852039166/');
expect(res.status).toBe(403);
});
it('blocks GCP metadata DNS', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://metadata.google.internal/');
expect(res.status).toBe(403);
});
it('blocks Alibaba metadata', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://100.100.100.200/');
expect(res.status).toBe(403);
});
it('blocks gopher:// scheme', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'gopher://evil:70/');
expect(res.status).toBe(400);
});
it('blocks IPv4-mapped IPv6 metadata', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://[::ffff:169.254.169.254]/');
expect(res.status).toBe(403);
});
it('blocks AWS IPv6 metadata', async () => {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://[fd00:ec2::254]/');
expect(res.status).toBe(403);
});
it('accepts POST method (needed for proxying APIs)', async () => {
const res = await request(app).post('/cors-proxy').set('Target-URL', 'http://169.254.169.254/');
expect(res.status).toBe(403); // blocked by SSRF, but NOT by a method filter
});
it('rejects malformed CustomHeaders', async () => {
const res = await request(app)
.get('/cors-proxy')
.set('Target-URL', 'http://example.com')
.set('CustomHeaders', '{bad');
expect(res.status).toBe(400);
expect(res.body.error).toContain('malformed JSON');
});
it('handles OPTIONS preflight', async () => {
const res = await request(app)
.options('/cors-proxy')
.set('Origin', 'http://localhost')
.set('Access-Control-Request-Method', 'GET');
expect(res.status).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe('*');
});
it('resolves env-var placeholder in Target-URL before validating', async () => {
process.env.DASHY_TEST_BLOCKED_HOST = '169.254.169.254';
try {
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://DASHY_TEST_BLOCKED_HOST/');
expect(res.status).toBe(403);
expect(res.body.error).toContain('169.254.169.254');
} finally {
delete process.env.DASHY_TEST_BLOCKED_HOST;
}
});
});
// Spin up a controllable target server, then drive the proxy through real
// failure paths (upstream 4xx/5xx, network refused, port unreachable). This
// is the only way to verify the error-classification branches end-to-end —
// vi.mock can't reach into a `require()` graph that's already been loaded
// by services/app at the top of this file.
describe('CORS proxy error classification', () => {
let target;
let targetUrl;
let handler = (req, res) => res.status(500).end();
beforeAll(() => new Promise((resolve) => {
target = http.createServer((req, res) => handler(req, res));
target.listen(0, '127.0.0.1', () => {
targetUrl = `http://127.0.0.1:${target.address().port}`;
resolve();
});
}));
afterAll(() => new Promise((resolve) => target.close(resolve)));
it('mirrors upstream 4xx status (so frontend can branch on err.response.status)', async () => {
handler = (req, res) => {
res.statusCode = 429;
res.statusMessage = 'Too Many Requests';
res.end('rate limited');
};
const res = await request(app).get('/cors-proxy').set('Target-URL', targetUrl);
expect(res.status).toBe(429);
expect(res.body.error.type).toBe('upstream_status');
expect(res.body.error.status).toBe(429);
expect(res.body.error.statusText).toBe('Too Many Requests');
});
it('mirrors upstream 5xx status', async () => {
handler = (req, res) => { res.statusCode = 503; res.end('busy'); };
const res = await request(app).get('/cors-proxy').set('Target-URL', targetUrl);
expect(res.status).toBe(503);
expect(res.body.error.type).toBe('upstream_status');
});
it('returns 502 + upstream_error when upstream refuses connection', async () => {
// Port 1 is reserved (tcpmux) — guaranteed nothing listening, ECONNREFUSED
const res = await request(app).get('/cors-proxy').set('Target-URL', 'http://127.0.0.1:1');
expect(res.status).toBe(502);
expect(res.body.error.type).toBe('upstream_error');
// Specific cause is still preserved in the body, just not in the type label
expect(res.body.error.code).toBe('ECONNREFUSED');
});
it('does not crash when target server hangs up mid-request', async () => {
handler = (req, res) => { res.socket.destroy(); };
const res = await request(app).get('/cors-proxy').set('Target-URL', targetUrl);
expect(res.status).toBe(502);
expect(res.body.error.type).toBe('upstream_error');
});
});
// Verify the request.js → cors-proxy timeout contract end-to-end. The proxy
// classifies via a `timeout: true` marker on the RequestError, not via the
// 'ECONNABORTED' string (which collides with a real libuv errno).
describe('request.js timeout marker', () => {
const request_ = require('../../services/request');
let target;
let targetUrl;
beforeAll(() => new Promise((resolve) => {
target = http.createServer(() => { /* never respond */ });
target.listen(0, '127.0.0.1', () => {
targetUrl = `http://127.0.0.1:${target.address().port}`;
resolve();
});
}));
afterAll(() => new Promise((resolve) => target.close(resolve)));
it('rejects with timeout:true (and ECONNABORTED preserved on .code for back-compat)', async () => {
await expect(request_({ url: targetUrl, timeout: 50 })).rejects.toMatchObject({
timeout: true,
code: 'ECONNABORTED',
});
});
});
describe('CORS proxy env-var substitution', () => {
const SET_KEYS = [];
const setEnv = (k, v) => { SET_KEYS.push(k); process.env[k] = v; };
afterEach(() => { while (SET_KEYS.length) delete process.env[SET_KEYS.pop()]; });
let warnSpy;
beforeAll(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
afterAll(() => { warnSpy.mockRestore(); });
it('replaces a DASHY_ token with its env value', () => {
setEnv('DASHY_FOO', 'secret');
expect(substituteEnv('Bearer DASHY_FOO')).toBe('Bearer secret');
});
it('replaces VITE_APP_ and VUE_APP_ tokens (back-compat)', () => {
setEnv('VITE_APP_KEY', 'abc');
setEnv('VUE_APP_KEY', 'xyz');
expect(substituteEnv('VITE_APP_KEY')).toBe('abc');
expect(substituteEnv('VUE_APP_KEY')).toBe('xyz');
});
it('leaves unset placeholders verbatim', () => {
expect(substituteEnv('Bearer DASHY_NOT_SET')).toBe('Bearer DASHY_NOT_SET');
});
it('does not substitute non-prefixed env vars', () => {
setEnv('AWS_SECRET_KEY', 'should-not-leak');
expect(substituteEnv('AWS_SECRET_KEY')).toBe('AWS_SECRET_KEY');
});
it('replaces multiple placeholders in one string', () => {
setEnv('DASHY_HOST', 'pi.local');
setEnv('DASHY_KEY', 'k1');
expect(substituteEnv('http://DASHY_HOST/api?key=DASHY_KEY')).toBe('http://pi.local/api?key=k1');
});
it('walks into objects and arrays', () => {
setEnv('DASHY_PASS', 'p4ss');
setEnv('DASHY_USER', 'admin');
const input = { auth: { user: 'DASHY_USER', pass: 'DASHY_PASS' }, tags: ['DASHY_PASS'] };
expect(substituteEnv(input)).toEqual({
auth: { user: 'admin', pass: 'p4ss' },
tags: ['p4ss'],
});
});
it('passes through non-string, non-object values unchanged', () => {
expect(substituteEnv(undefined)).toBe(undefined);
expect(substituteEnv(null)).toBe(null);
expect(substituteEnv(42)).toBe(42);
expect(substituteEnv(true)).toBe(true);
});
it('substitutes mixed-case suffix (matches process.env case-sensitively)', () => {
setEnv('VITE_APP_pihole_ip', '10.0.0.1');
expect(substituteEnv('http://VITE_APP_pihole_ip/api')).toBe('http://10.0.0.1/api');
});
it('warns once per unique unset placeholder, then stays quiet', () => {
warnSpy.mockClear();
// Use names unlikely to collide with any earlier warnings in this run
substituteEnv('DASHY_WARN_TEST_ONE');
substituteEnv('DASHY_WARN_TEST_ONE'); // repeat - should not re-log
substituteEnv('DASHY_WARN_TEST_TWO');
expect(warnSpy).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls[0][0]).toContain('DASHY_WARN_TEST_ONE');
expect(warnSpy.mock.calls[1][0]).toContain('DASHY_WARN_TEST_TWO');
});
});