portok 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Security Tests
3
+ * Tests token validation, IP allowlist, and rate limiting
4
+ */
5
+
6
+ import { describe, it, before, after } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { spawn } from 'node:child_process';
9
+ import { createMockServer, getFreePort, waitFor } from './helpers/mock-server.mjs';
10
+
11
+ describe('Security', () => {
12
+ let mockServer;
13
+ let proxyPort;
14
+ let daemonProcess;
15
+ const adminToken = 'secure-test-token-xyz';
16
+
17
+ before(async () => {
18
+ mockServer = await createMockServer({ port: 0 });
19
+ proxyPort = await getFreePort();
20
+
21
+ daemonProcess = spawn('node', ['portokd.mjs'], {
22
+ env: {
23
+ ...process.env,
24
+ LISTEN_PORT: String(proxyPort),
25
+ INITIAL_TARGET_PORT: String(mockServer.port),
26
+ ADMIN_TOKEN: adminToken,
27
+ STATE_FILE: `/tmp/portok-security-test-${Date.now()}.json`,
28
+ ADMIN_ALLOWLIST: '127.0.0.1,::1,::ffff:127.0.0.1',
29
+ DRAIN_MS: '1000',
30
+ ROLLBACK_WINDOW_MS: '60000',
31
+ ROLLBACK_CHECK_EVERY_MS: '5000',
32
+ ROLLBACK_FAIL_THRESHOLD: '3',
33
+ ADMIN_RATE_LIMIT: '1000',
34
+ },
35
+ stdio: 'pipe',
36
+ });
37
+
38
+ await waitFor(async () => {
39
+ try {
40
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
41
+ headers: { 'x-admin-token': adminToken },
42
+ });
43
+ return res.ok;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }, 5000);
48
+ });
49
+
50
+ after(async () => {
51
+ if (daemonProcess) {
52
+ daemonProcess.kill('SIGTERM');
53
+ }
54
+ if (mockServer) await mockServer.close();
55
+ });
56
+
57
+ describe('Token Validation', () => {
58
+ it('should reject request without token', async () => {
59
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`);
60
+ assert.strictEqual(response.status, 401);
61
+
62
+ const data = await response.json();
63
+ assert.ok(data.error.includes('Unauthorized'));
64
+ });
65
+
66
+ it('should reject request with invalid token', async () => {
67
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
68
+ headers: { 'x-admin-token': 'wrong-token' },
69
+ });
70
+ assert.strictEqual(response.status, 401);
71
+ });
72
+
73
+ it('should accept request with correct token', async () => {
74
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
75
+ headers: { 'x-admin-token': adminToken },
76
+ });
77
+ assert.strictEqual(response.status, 200);
78
+ });
79
+
80
+ it('should use timing-safe comparison', async () => {
81
+ // Test various token lengths to ensure timing-safe comparison
82
+ const testTokens = [
83
+ '',
84
+ 'a',
85
+ 'short',
86
+ adminToken.substring(0, 5),
87
+ adminToken + 'extra',
88
+ adminToken,
89
+ ];
90
+
91
+ for (const token of testTokens) {
92
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
93
+ headers: { 'x-admin-token': token },
94
+ });
95
+
96
+ if (token === adminToken) {
97
+ assert.strictEqual(response.status, 200);
98
+ } else {
99
+ assert.strictEqual(response.status, 401);
100
+ }
101
+ }
102
+ });
103
+
104
+ it('should require token for all admin endpoints', async () => {
105
+ const endpoints = [
106
+ { path: '/__status', method: 'GET' },
107
+ { path: '/__metrics', method: 'GET' },
108
+ { path: '/__health', method: 'GET' },
109
+ { path: `/__switch?port=${mockServer.port}`, method: 'POST' },
110
+ ];
111
+
112
+ for (const { path, method } of endpoints) {
113
+ const response = await fetch(`http://127.0.0.1:${proxyPort}${path}`, {
114
+ method,
115
+ });
116
+ assert.strictEqual(response.status, 401, `Expected 401 for ${method} ${path}`);
117
+ }
118
+ });
119
+ });
120
+
121
+ describe('Admin Endpoints vs Proxy', () => {
122
+ it('should not require token for proxied requests', async () => {
123
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
124
+ assert.strictEqual(response.status, 200);
125
+ });
126
+
127
+ it('should proxy /status (not admin endpoint)', async () => {
128
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/status`);
129
+ // Should go to backend, not be treated as admin
130
+ assert.strictEqual(response.status, 200);
131
+ });
132
+
133
+ it('should only treat /__ prefix as admin', async () => {
134
+ // /_status should be proxied, not admin
135
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/_status`);
136
+ assert.strictEqual(response.status, 200);
137
+ });
138
+ });
139
+
140
+ describe('Rate Limiting', () => {
141
+ it('should not rate limit proxied requests', async () => {
142
+ // Make many proxied requests
143
+ const responses = [];
144
+ for (let i = 0; i < 20; i++) {
145
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
146
+ responses.push(response.status);
147
+ }
148
+
149
+ // All should succeed
150
+ const successCount = responses.filter(s => s === 200).length;
151
+ assert.strictEqual(successCount, 20);
152
+ });
153
+ });
154
+
155
+ describe('Target Host Security (SSRF Guard)', () => {
156
+ it('should only proxy to localhost', async () => {
157
+ // All requests should go to 127.0.0.1
158
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`);
159
+ const data = await response.json();
160
+
161
+ // Verify the request reached our mock server on localhost
162
+ assert.ok(data.headers.host);
163
+ });
164
+
165
+ it('should not allow port override via headers', async () => {
166
+ // Try to trick proxy with Host header
167
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/echo`, {
168
+ headers: {
169
+ 'Host': 'evil.com:9999',
170
+ },
171
+ });
172
+
173
+ // Should still reach our mock server
174
+ assert.strictEqual(response.status, 200);
175
+ });
176
+ });
177
+ });
178
+
179
+ describe('Security - Rate Limit Test', () => {
180
+ let mockServer;
181
+ let proxyPort;
182
+ let daemonProcess;
183
+ const adminToken = 'rate-limit-test-token';
184
+
185
+ before(async () => {
186
+ mockServer = await createMockServer({ port: 0 });
187
+ proxyPort = await getFreePort();
188
+
189
+ // Use rate limit of 15 - waitFor uses ~5 requests, then we make 15 more
190
+ daemonProcess = spawn('node', ['portokd.mjs'], {
191
+ env: {
192
+ ...process.env,
193
+ LISTEN_PORT: String(proxyPort),
194
+ INITIAL_TARGET_PORT: String(mockServer.port),
195
+ ADMIN_TOKEN: adminToken,
196
+ STATE_FILE: `/tmp/portok-ratelimit-test-${Date.now()}.json`,
197
+ DRAIN_MS: '1000',
198
+ ROLLBACK_WINDOW_MS: '60000',
199
+ ROLLBACK_CHECK_EVERY_MS: '5000',
200
+ ROLLBACK_FAIL_THRESHOLD: '3',
201
+ ADMIN_RATE_LIMIT: '15',
202
+ },
203
+ stdio: 'pipe',
204
+ });
205
+
206
+ await waitFor(async () => {
207
+ try {
208
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
209
+ headers: { 'x-admin-token': adminToken },
210
+ });
211
+ return res.ok;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }, 5000);
216
+ });
217
+
218
+ after(async () => {
219
+ if (daemonProcess) {
220
+ daemonProcess.kill('SIGTERM');
221
+ }
222
+ if (mockServer) await mockServer.close();
223
+ });
224
+
225
+ it('should rate limit admin endpoints', async () => {
226
+ // Make 20 requests rapidly - some should get rate limited
227
+ const responses = [];
228
+
229
+ for (let i = 0; i < 20; i++) {
230
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
231
+ headers: { 'x-admin-token': adminToken },
232
+ });
233
+ responses.push(response.status);
234
+ }
235
+
236
+ const successCount = responses.filter(s => s === 200).length;
237
+ const rateLimitedCount = responses.filter(s => s === 429).length;
238
+
239
+ // Some should succeed, some should be rate limited
240
+ assert.ok(successCount > 0, `Expected some successes, got ${successCount}`);
241
+ assert.ok(rateLimitedCount > 0, `Expected some rate limited responses, got ${rateLimitedCount}`);
242
+ });
243
+
244
+ it('should return 429 with proper error message', async () => {
245
+ // Should be rate limited from previous test
246
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
247
+ headers: { 'x-admin-token': adminToken },
248
+ });
249
+
250
+ // Should be rate limited
251
+ assert.strictEqual(response.status, 429);
252
+ const data = await response.json();
253
+ assert.ok(data.error.includes('Rate limit'));
254
+ });
255
+ });
256
+
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Health-Gated Switching Tests
3
+ * Tests the /__switch endpoint with health checks
4
+ */
5
+
6
+ import { describe, it, before, after, beforeEach } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import { spawn } from 'node:child_process';
9
+ import { createMockServer, getFreePort, waitFor, adminRequest } from './helpers/mock-server.mjs';
10
+
11
+ describe('Health-Gated Switching', () => {
12
+ let mockServer1;
13
+ let mockServer2;
14
+ let proxyPort;
15
+ let daemonProcess;
16
+ const adminToken = 'test-token-switch';
17
+
18
+ before(async () => {
19
+ // Create two mock backend servers
20
+ mockServer1 = await createMockServer({ port: 0 });
21
+ mockServer2 = await createMockServer({ port: 0 });
22
+
23
+ // Get a free port for the proxy
24
+ proxyPort = await getFreePort();
25
+
26
+ // Start the daemon
27
+ daemonProcess = spawn('node', ['portokd.mjs'], {
28
+ env: {
29
+ ...process.env,
30
+ LISTEN_PORT: String(proxyPort),
31
+ INITIAL_TARGET_PORT: String(mockServer1.port),
32
+ ADMIN_TOKEN: adminToken,
33
+ STATE_FILE: `/tmp/portok-switch-test-${Date.now()}.json`,
34
+ DRAIN_MS: '500',
35
+ ROLLBACK_WINDOW_MS: '10000',
36
+ ROLLBACK_CHECK_EVERY_MS: '1000',
37
+ ROLLBACK_FAIL_THRESHOLD: '3',
38
+ ADMIN_RATE_LIMIT: '1000',
39
+ },
40
+ stdio: 'pipe',
41
+ });
42
+
43
+ // Wait for daemon to start
44
+ await waitFor(async () => {
45
+ try {
46
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
47
+ headers: { 'x-admin-token': adminToken },
48
+ });
49
+ return res.ok;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }, 5000);
54
+ });
55
+
56
+ after(async () => {
57
+ if (daemonProcess) {
58
+ daemonProcess.kill('SIGTERM');
59
+ }
60
+ if (mockServer1) await mockServer1.close();
61
+ if (mockServer2) await mockServer2.close();
62
+ });
63
+
64
+ beforeEach(() => {
65
+ mockServer1.setHealthy(true);
66
+ mockServer2.setHealthy(true);
67
+ });
68
+
69
+ describe('Successful Switch', () => {
70
+ it('should switch to healthy port', async () => {
71
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
72
+ method: 'POST',
73
+ headers: { 'x-admin-token': adminToken },
74
+ });
75
+
76
+ assert.strictEqual(response.status, 200);
77
+
78
+ const data = await response.json();
79
+ assert.strictEqual(data.success, true);
80
+ assert.strictEqual(data.switch.to, mockServer2.port);
81
+ assert.strictEqual(data.switch.from, mockServer1.port);
82
+ assert.strictEqual(data.switch.reason, 'manual');
83
+ assert.ok(data.switch.id);
84
+ });
85
+
86
+ it('should update status after switch', async () => {
87
+ // First switch to server2
88
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
89
+ method: 'POST',
90
+ headers: { 'x-admin-token': adminToken },
91
+ });
92
+
93
+ // Check status
94
+ const statusRes = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
95
+ headers: { 'x-admin-token': adminToken },
96
+ });
97
+
98
+ const status = await statusRes.json();
99
+ assert.strictEqual(status.activePort, mockServer2.port);
100
+ assert.ok(status.drainUntil); // Drain should be active
101
+ assert.strictEqual(status.lastSwitch.to, mockServer2.port);
102
+ });
103
+
104
+ it('should route traffic to new port after switch', async () => {
105
+ // Switch to server2
106
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
107
+ method: 'POST',
108
+ headers: { 'x-admin-token': adminToken },
109
+ });
110
+
111
+ // Clear both servers' request logs
112
+ mockServer1.clearRequests();
113
+ mockServer2.clearRequests();
114
+
115
+ // Make a request - should go to server2 for new connections
116
+ // (Existing connections might still go to server1 during drain)
117
+ await new Promise(resolve => setTimeout(resolve, 100));
118
+
119
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/`);
120
+ assert.strictEqual(response.status, 200);
121
+ });
122
+ });
123
+
124
+ describe('Failed Switch - Unhealthy Port', () => {
125
+ it('should reject switch to unhealthy port with 409', async () => {
126
+ // Make server2 unhealthy
127
+ mockServer2.setHealthy(false);
128
+
129
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
130
+ method: 'POST',
131
+ headers: { 'x-admin-token': adminToken },
132
+ });
133
+
134
+ assert.strictEqual(response.status, 409);
135
+
136
+ const data = await response.json();
137
+ assert.ok(data.error.includes('Health check failed'));
138
+ });
139
+
140
+ it('should not change active port when switch fails', async () => {
141
+ // Get current status
142
+ const statusBefore = await adminRequest(`http://127.0.0.1:${proxyPort}`, '/__status', { token: adminToken });
143
+ const portBefore = statusBefore.data.activePort;
144
+
145
+ // Make server2 unhealthy and try to switch
146
+ mockServer2.setHealthy(false);
147
+
148
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
149
+ method: 'POST',
150
+ headers: { 'x-admin-token': adminToken },
151
+ });
152
+
153
+ // Check status unchanged
154
+ const statusAfter = await adminRequest(`http://127.0.0.1:${proxyPort}`, '/__status', { token: adminToken });
155
+ assert.strictEqual(statusAfter.data.activePort, portBefore);
156
+ });
157
+ });
158
+
159
+ describe('Switch Validation', () => {
160
+ it('should reject invalid port (non-numeric)', async () => {
161
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=invalid`, {
162
+ method: 'POST',
163
+ headers: { 'x-admin-token': adminToken },
164
+ });
165
+
166
+ assert.strictEqual(response.status, 400);
167
+
168
+ const data = await response.json();
169
+ assert.ok(data.error.includes('Invalid port'));
170
+ });
171
+
172
+ it('should reject port out of range', async () => {
173
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=99999`, {
174
+ method: 'POST',
175
+ headers: { 'x-admin-token': adminToken },
176
+ });
177
+
178
+ assert.strictEqual(response.status, 400);
179
+ });
180
+
181
+ it('should reject port 0', async () => {
182
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=0`, {
183
+ method: 'POST',
184
+ headers: { 'x-admin-token': adminToken },
185
+ });
186
+
187
+ assert.strictEqual(response.status, 400);
188
+ });
189
+
190
+ it('should reject missing port parameter', async () => {
191
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch`, {
192
+ method: 'POST',
193
+ headers: { 'x-admin-token': adminToken },
194
+ });
195
+
196
+ assert.strictEqual(response.status, 400);
197
+ });
198
+ });
199
+
200
+ describe('Health Endpoint', () => {
201
+ it('should report healthy when active port is healthy', async () => {
202
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
203
+ headers: { 'x-admin-token': adminToken },
204
+ });
205
+
206
+ assert.strictEqual(response.status, 200);
207
+
208
+ const data = await response.json();
209
+ assert.strictEqual(data.healthy, true);
210
+ });
211
+
212
+ it('should report unhealthy when active port is unhealthy', async () => {
213
+ // Get current active port from status
214
+ const statusRes = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
215
+ headers: { 'x-admin-token': adminToken },
216
+ });
217
+ const status = await statusRes.json();
218
+
219
+ // Make current active server unhealthy
220
+ if (status.activePort === mockServer1.port) {
221
+ mockServer1.setHealthy(false);
222
+ } else {
223
+ mockServer2.setHealthy(false);
224
+ }
225
+
226
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__health`, {
227
+ headers: { 'x-admin-token': adminToken },
228
+ });
229
+
230
+ assert.strictEqual(response.status, 503);
231
+
232
+ const data = await response.json();
233
+ assert.strictEqual(data.healthy, false);
234
+ });
235
+ });
236
+
237
+ describe('Switch ID', () => {
238
+ it('should generate unique switch IDs', async () => {
239
+ const ids = new Set();
240
+
241
+ for (let i = 0; i < 3; i++) {
242
+ const response = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer1.port}`, {
243
+ method: 'POST',
244
+ headers: { 'x-admin-token': adminToken },
245
+ });
246
+
247
+ const data = await response.json();
248
+ ids.add(data.switch.id);
249
+
250
+ // Switch back
251
+ await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
252
+ method: 'POST',
253
+ headers: { 'x-admin-token': adminToken },
254
+ });
255
+ }
256
+
257
+ assert.strictEqual(ids.size, 3); // All IDs should be unique
258
+ });
259
+ });
260
+ });
261
+