| |
| |
| |
| |
| |
| |
|
|
| import { afterEach, describe, it } from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { config } from '../src/config.js'; |
| import { configureBindHost } from '../src/auth.js'; |
| import { handleDashboardApi } from '../src/dashboard/api.js'; |
|
|
| const original = { |
| apiKey: config.apiKey, |
| dashboardPassword: config.dashboardPassword, |
| allowPrivateProxyHosts: config.allowPrivateProxyHosts, |
| }; |
|
|
| function mkRes() { |
| const captured = { status: null, body: null }; |
| const res = { |
| headersSent: false, |
| writeHead(status) { captured.status = status; res.headersSent = true; return res; }, |
| end(p) { try { captured.body = JSON.parse(p); } catch { captured.body = p; } }, |
| setHeader() {}, on() {}, |
| }; |
| return { res, captured }; |
| } |
|
|
| function mkAuthedReq() { |
| return { headers: { 'x-dashboard-password': 'admin-pw' }, socket: { remoteAddress: '203.0.113.5' } }; |
| } |
|
|
| afterEach(() => { |
| config.apiKey = original.apiKey; |
| config.dashboardPassword = original.dashboardPassword; |
| config.allowPrivateProxyHosts = original.allowPrivateProxyHosts; |
| configureBindHost('0.0.0.0'); |
| }); |
|
|
| describe('dashboard /proxy setter routes — private-host gate (audit H3)', () => { |
| it('PUT /proxy/global with host=127.0.0.1 returns 400 when ALLOW_PRIVATE_PROXY_HOSTS unset', async () => { |
| config.apiKey = ''; |
| config.dashboardPassword = 'admin-pw'; |
| config.allowPrivateProxyHosts = false; |
| configureBindHost('0.0.0.0'); |
|
|
| const { res, captured } = mkRes(); |
| await handleDashboardApi( |
| 'PUT', '/proxy/global', |
| { type: 'http', host: '127.0.0.1', port: 8080 }, |
| mkAuthedReq(), res, |
| ); |
| assert.equal(captured.status, 400, 'private host must be rejected at /proxy/global'); |
| assert.ok(/PROXY_PRIVATE/i.test(JSON.stringify(captured.body) || ''), 'error code mentions private-host gate'); |
| }); |
|
|
| it('PUT /proxy/global with host=169.254.169.254 (cloud metadata) returns 400', async () => { |
| config.apiKey = ''; |
| config.dashboardPassword = 'admin-pw'; |
| config.allowPrivateProxyHosts = false; |
| configureBindHost('0.0.0.0'); |
|
|
| const { res, captured } = mkRes(); |
| await handleDashboardApi( |
| 'PUT', '/proxy/global', |
| { type: 'http', host: '169.254.169.254', port: 80 }, |
| mkAuthedReq(), res, |
| ); |
| assert.equal(captured.status, 400, 'cloud-metadata IP must be rejected'); |
| }); |
|
|
| it('PUT /proxy/accounts/:id with host=10.0.0.1 returns 400 (RFC1918 private)', async () => { |
| config.apiKey = ''; |
| config.dashboardPassword = 'admin-pw'; |
| config.allowPrivateProxyHosts = false; |
| configureBindHost('0.0.0.0'); |
|
|
| const { res, captured } = mkRes(); |
| await handleDashboardApi( |
| 'PUT', '/proxy/accounts/some-id', |
| { type: 'http', host: '10.0.0.1', port: 3128 }, |
| mkAuthedReq(), res, |
| ); |
| assert.equal(captured.status, 400, 'RFC1918 must be rejected at per-account proxy'); |
| }); |
|
|
| it('ALLOW_PRIVATE_PROXY_HOSTS=1 disables the gate (operator escape hatch)', async () => { |
| config.apiKey = ''; |
| config.dashboardPassword = 'admin-pw'; |
| config.allowPrivateProxyHosts = true; |
| configureBindHost('0.0.0.0'); |
|
|
| const { res, captured } = mkRes(); |
| await handleDashboardApi( |
| 'PUT', '/proxy/global', |
| { type: 'http', host: '127.0.0.1', port: 8080 }, |
| mkAuthedReq(), res, |
| ); |
| assert.equal(captured.status, 200, 'opt-in env should let operators set private hosts'); |
| }); |
| }); |
|
|