Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
418 lines
14 KiB
JavaScript
418 lines
14 KiB
JavaScript
/**
|
|
* Integration tests for server.js input validation
|
|
* Tests that routes properly reject invalid input before reaching business logic
|
|
*/
|
|
|
|
const request = require('supertest');
|
|
const app = require('../server');
|
|
|
|
describe('POST /api/assets/upload - directory traversal prevention', () => {
|
|
test('rejects filename with path separators', async () => {
|
|
const res = await request(app)
|
|
.post('/api/assets/upload')
|
|
.send({ filename: '../../../etc/passwd', data: 'data:image/png;base64,iVBOR' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/path separator/i);
|
|
});
|
|
|
|
test('rejects filename with backslash', async () => {
|
|
const res = await request(app)
|
|
.post('/api/assets/upload')
|
|
.send({ filename: '..\\..\\config.json', data: 'data:image/png;base64,iVBOR' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/path separator/i);
|
|
});
|
|
|
|
test('rejects filename with dot-dot', async () => {
|
|
const res = await request(app)
|
|
.post('/api/assets/upload')
|
|
.send({ filename: '..evil.png', data: 'data:image/png;base64,iVBOR' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/path separator/i);
|
|
});
|
|
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/assets/upload')
|
|
.send({ filename: 'test.png' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/site - Caddyfile injection prevention', () => {
|
|
test('rejects invalid domain format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site')
|
|
.send({ domain: 'evil;rm -rf /', upstream: '127.0.0.1:8080' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid domain/i);
|
|
});
|
|
|
|
test('rejects domain with spaces', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site')
|
|
.send({ domain: 'evil domain', upstream: '127.0.0.1:8080' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid domain/i);
|
|
});
|
|
|
|
test('rejects invalid upstream format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site')
|
|
.send({ domain: 'test.sami', upstream: 'not a valid upstream' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid upstream/i);
|
|
});
|
|
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site')
|
|
.send({ domain: 'test.sami' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/site/external - URL and subdomain validation', () => {
|
|
test('rejects invalid subdomain', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site/external')
|
|
.send({ subdomain: '-invalid', externalUrl: 'https://example.com' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid subdomain/i);
|
|
});
|
|
|
|
test('rejects subdomain with special chars', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site/external')
|
|
.send({ subdomain: 'test;evil', externalUrl: 'https://example.com' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid subdomain/i);
|
|
});
|
|
|
|
test('rejects invalid URL', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site/external')
|
|
.send({ subdomain: 'myapp', externalUrl: 'not-a-url' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/site/external')
|
|
.send({ subdomain: 'myapp' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
});
|
|
|
|
// DNS routes require a token to bypass the 401 token check and reach validation
|
|
const FAKE_TOKEN = 'aaaa1111bbbb2222cccc3333dddd4444';
|
|
|
|
describe('POST /api/dns/record - DNS injection prevention', () => {
|
|
test('rejects invalid domain format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'evil;command', ip: '10.0.0.1', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid domain/i);
|
|
});
|
|
|
|
test('rejects invalid IP address', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'test.sami', ip: 'not-an-ip', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid ip/i);
|
|
});
|
|
|
|
test('rejects TTL out of range (too low)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 5, token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/ttl/i);
|
|
});
|
|
|
|
test('rejects TTL out of range (too high)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 100000, token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/ttl/i);
|
|
});
|
|
|
|
test('rejects invalid server IP', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'test.sami', ip: '10.0.0.1', server: 'not-an-ip', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid dns server/i);
|
|
});
|
|
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/record')
|
|
.send({ domain: 'test.sami', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /api/dns/record - DNS injection prevention', () => {
|
|
test('rejects invalid domain', async () => {
|
|
const res = await request(app)
|
|
.delete('/api/dns/record')
|
|
.query({ domain: 'evil;drop table', token: 'abc123def456' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid domain/i);
|
|
});
|
|
|
|
test('rejects invalid record type', async () => {
|
|
const res = await request(app)
|
|
.delete('/api/dns/record')
|
|
.query({ domain: 'test.sami', type: 'INVALID', token: 'abc123def456' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid dns record type/i);
|
|
});
|
|
|
|
test('rejects invalid ipAddress', async () => {
|
|
const res = await request(app)
|
|
.delete('/api/dns/record')
|
|
.query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'abc123def456' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid ip/i);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/dns/resolve - DNS injection prevention', () => {
|
|
test('rejects invalid domain', async () => {
|
|
const res = await request(app)
|
|
.get('/api/dns/resolve')
|
|
.query({ domain: 'evil;command', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid domain/i);
|
|
});
|
|
|
|
test('rejects invalid server IP', async () => {
|
|
const res = await request(app)
|
|
.get('/api/dns/resolve')
|
|
.query({ domain: 'test.sami', server: 'not-an-ip', token: FAKE_TOKEN });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid dns server/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/apps/deploy - deployment validation', () => {
|
|
test('rejects invalid subdomain', async () => {
|
|
const res = await request(app)
|
|
.post('/api/apps/deploy')
|
|
.send({ appId: 'plex', config: { subdomain: '-bad-sub' } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid subdomain/i);
|
|
});
|
|
|
|
test('rejects invalid port', async () => {
|
|
const res = await request(app)
|
|
.post('/api/apps/deploy')
|
|
.send({ appId: 'plex', config: { subdomain: 'test', port: 99999 } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid port/i);
|
|
});
|
|
|
|
test('rejects invalid IP', async () => {
|
|
const res = await request(app)
|
|
.post('/api/apps/deploy')
|
|
.send({ appId: 'plex', config: { subdomain: 'test', ip: 'not-ip' } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid ip/i);
|
|
});
|
|
|
|
test('rejects unknown template', async () => {
|
|
const res = await request(app)
|
|
.post('/api/apps/deploy')
|
|
.send({ appId: 'nonexistent-app-xyz', config: { subdomain: 'test' } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid app template/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/dns/credentials - credential validation', () => {
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/credentials')
|
|
.send({ username: 'admin' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
|
|
test('rejects username exceeding max length', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/credentials')
|
|
.send({ username: 'a'.repeat(101), password: 'secret' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/maximum length/i);
|
|
});
|
|
|
|
test('rejects username with injection chars', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/credentials')
|
|
.send({ username: 'admin;rm -rf /', password: 'secret' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid characters/i);
|
|
});
|
|
|
|
test('rejects username with pipe', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/credentials')
|
|
.send({ username: 'admin|evil', password: 'secret' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid characters/i);
|
|
});
|
|
|
|
test('rejects invalid server IP', async () => {
|
|
const res = await request(app)
|
|
.post('/api/dns/credentials')
|
|
.send({ username: 'admin', password: 'secret', server: 'not-ip' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid dns server/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/services - service config validation', () => {
|
|
test('rejects missing fields', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services')
|
|
.send({ id: 'test' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
|
|
test('rejects invalid service id format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services')
|
|
.send({ id: 'invalid id with spaces!', name: 'Test' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('PUT /api/services - bulk import validation', () => {
|
|
test('rejects non-array body', async () => {
|
|
const res = await request(app)
|
|
.put('/api/services')
|
|
.send({ id: 'test', name: 'Test' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/array/i);
|
|
});
|
|
|
|
test('rejects service with invalid id', async () => {
|
|
const res = await request(app)
|
|
.put('/api/services')
|
|
.send([{ id: 'invalid id!', name: 'Test' }]);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/services/update - service update validation', () => {
|
|
test('rejects missing subdomains', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services/update')
|
|
.send({ oldSubdomain: 'test' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/required/i);
|
|
});
|
|
|
|
test('rejects invalid subdomain format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services/update')
|
|
.send({ oldSubdomain: '-bad', newSubdomain: 'good' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid subdomain/i);
|
|
});
|
|
|
|
test('rejects invalid port', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services/update')
|
|
.send({ oldSubdomain: 'old', newSubdomain: 'new', port: 70000 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid port/i);
|
|
});
|
|
|
|
test('rejects invalid IP', async () => {
|
|
const res = await request(app)
|
|
.post('/api/services/update')
|
|
.send({ oldSubdomain: 'old', newSubdomain: 'new', ip: 'not-ip' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/invalid ip/i);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/arr/test-connection - SSRF prevention', () => {
|
|
test('rejects invalid URL', async () => {
|
|
const res = await request(app)
|
|
.post('/api/arr/test-connection')
|
|
.send({ service: 'radarr', url: 'not-a-url', apiKey: 'abc123def456' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
|
|
test('rejects invalid API key format', async () => {
|
|
const res = await request(app)
|
|
.post('/api/arr/test-connection')
|
|
.send({ service: 'radarr', url: 'http://localhost:7878', apiKey: 'a;b' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/notifications/config - notification provider validation', () => {
|
|
test('rejects invalid Discord webhook URL', async () => {
|
|
const res = await request(app)
|
|
.post('/api/notifications/config')
|
|
.send({ providers: { discord: { webhookUrl: 'not-a-url' } } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/discord webhook/i);
|
|
});
|
|
|
|
test('rejects invalid ntfy server URL', async () => {
|
|
const res = await request(app)
|
|
.post('/api/notifications/config')
|
|
.send({ providers: { ntfy: { serverUrl: 'ftp://bad' } } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/ntfy server/i);
|
|
});
|
|
|
|
test('rejects invalid ntfy topic', async () => {
|
|
const res = await request(app)
|
|
.post('/api/notifications/config')
|
|
.send({ providers: { ntfy: { topic: 'has spaces and $pecial!' } } });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/ntfy topic/i);
|
|
});
|
|
|
|
test('accepts valid config', async () => {
|
|
const res = await request(app)
|
|
.post('/api/notifications/config')
|
|
.send({ enabled: true });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Rate limiting headers', () => {
|
|
test('returns rate limit headers on API responses', async () => {
|
|
const res = await request(app).get('/api/health');
|
|
// Health endpoint is skipped by rate limiter, but general endpoints should have headers
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
test('general API endpoint has rate limiting configured', async () => {
|
|
const res = await request(app).get('/api/services');
|
|
// Rate limiting is skipped in test env, so verify the endpoint is accessible
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|