W
File size: 3,830 Bytes
2b64d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// v2.0.55 audit H3 regression — /dashboard/api/proxy/global PUT and
// /dashboard/api/proxy/accounts/:id PUT must run the same private-host
// gate the add-account path uses. Without it, a dashboard-authenticated
// caller (chat-API key on pre-v2.0.55) can pin the proxy at 127.0.0.1 /
// 169.254.169.254 / any internal socket and force LS/proxy egress
// toward internal services (cloud metadata, SMTP relays, etc.).

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');
  });
});