mirror of
https://github.com/Lissy93/dashy.git
synced 2026-06-06 00:34:30 -04:00
264 lines
9.9 KiB
JavaScript
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');
|
|
});
|
|
});
|