fix(mesh): accept node_proxy at the proxy-tunnel WS upgrade (#1050)

The mesh proxy-tunnel WS handler was gated on full-admin api_token scope
only, so node_proxy JWTs (the credential the Add Remote Node dialog tells
operators to generate via Settings -> Nodes -> Generate Token) were
silently rejected with HTTP 403. Operators followed the dialog's
instructions, enrolled the node cleanly, saw reachableMode='proxy' with
no negative badge, opted a stack into mesh, watched the redeploy
complete, and only then discovered no bytes flow.

node_proxy already authorizes every /api/* surface on the remote
(deploy, exec, host console, filesystem), which is a strict superset of
what the mesh proxy-tunnel does. Gating mesh more strictly was theatre,
not security, and it created a UX trap with no in-product signal pointing
at the scope mismatch.

Change the upgrade dispatcher to accept any machine-to-machine
credential: node_proxy JWT or full-admin api_token. Session cookies and
restricted api_token scopes (read-only, deploy-only) remain rejected
through the same paths as before. Adds positive/negative coverage in
upgrade-order.test.ts for all four credential shapes so the gate stays
pinned against future re-tightening. Updates user-facing copy in the
mesh docs and Settings -> API Tokens description so the documented path
matches the actual code.

If we ever introduce a demoted node_proxy variant (audit-only, read-only
fleet member), the right move is differentiated node_proxy scope claims
inside the tunnel JWT (tracker F-A3), not requiring a separate token
type for mesh.
This commit is contained in:
Anso
2026-05-14 23:26:41 -04:00
committed by GitHub
parent 4747b14d2a
commit 946db9df52
6 changed files with 96 additions and 16 deletions
+75 -1
View File
@@ -11,6 +11,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import type { AddressInfo } from 'net';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
@@ -50,9 +51,10 @@ describe('WebSocket upgrade dispatch order', () => {
cleanupTestDb(tmpDir);
});
function connect(pathAndQuery: string, opts: { cookie?: string } = {}): WebSocket {
function connect(pathAndQuery: string, opts: { cookie?: string; bearer?: string } = {}): WebSocket {
const headers: Record<string, string> = {};
if (opts.cookie) headers['cookie'] = opts.cookie;
if (opts.bearer) headers['authorization'] = `Bearer ${opts.bearer}`;
return new WebSocket(`ws://127.0.0.1:${port}${pathAndQuery}`, { headers });
}
@@ -143,6 +145,78 @@ describe('WebSocket upgrade dispatch order', () => {
try { ws.terminate(); } catch { /* ignore */ }
});
describe('/api/mesh/proxy-tunnel scope gating', () => {
// The mesh proxy-tunnel ingress is machine-to-machine. The dispatch
// ladder must accept the credentials that fleet enrollment produces
// (node_proxy JWTs) AND the full-admin api_token scope, while rejecting
// session cookies and restricted api_token scopes. These cases pin the
// scope contract so a future re-tightening cannot silently regress to
// the "full-admin only" behaviour that trapped operators following the
// Add Remote Node dialog's Node Token instructions.
it('accepts a node_proxy Bearer (fleet enrollment token) at the upgrade and reaches the handler', async () => {
const nodeProxyToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const ws = connect('/api/mesh/proxy-tunnel', { bearer: nodeProxyToken });
const outcome = await waitForOutcome(ws);
// Pilot-mode rejection (404) or normal open both indicate the upgrade
// passed the scope gate and was handed to handleMeshProxyTunnel.
// The pre-fix behaviour was unequivocal: HTTP 403 from the dispatcher.
expect(outcome.kind).not.toBe('unexpected');
if (outcome.kind === 'unexpected') {
expect(outcome.status).not.toBe(403);
}
try { ws.terminate(); } catch { /* ignore */ }
});
it('accepts a full-admin api_token at the upgrade and reaches the handler', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
DatabaseService.getInstance().addApiToken({
token_hash: tokenHash,
name: `mesh-scope-gate-${Date.now()}`,
scope: 'full-admin',
user_id: adminId,
created_at: Date.now(),
expires_at: null,
});
const ws = connect('/api/mesh/proxy-tunnel', { bearer: rawToken });
const outcome = await waitForOutcome(ws);
expect(outcome.kind).not.toBe('unexpected');
if (outcome.kind === 'unexpected') {
expect(outcome.status).not.toBe(403);
}
try { ws.terminate(); } catch { /* ignore */ }
});
it('rejects a read-only api_token at the upgrade with HTTP 403', async () => {
const { DatabaseService } = await import('../services/DatabaseService');
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const adminId = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!.id;
DatabaseService.getInstance().addApiToken({
token_hash: tokenHash,
name: `mesh-scope-readonly-${Date.now()}`,
scope: 'read-only',
user_id: adminId,
created_at: Date.now(),
expires_at: null,
});
const ws = connect('/api/mesh/proxy-tunnel', { bearer: rawToken });
const outcome = await waitForOutcome(ws);
expect(outcome.kind).toBe('unexpected');
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
});
it('rejects a session cookie at the upgrade with HTTP 403 (mesh is not a UI surface)', async () => {
const ws = connect('/api/mesh/proxy-tunnel', { cookie: sessionCookie });
const outcome = await waitForOutcome(ws);
expect(outcome.kind).toBe('unexpected');
if (outcome.kind === 'unexpected') expect(outcome.status).toBe(403);
});
});
it('dispatches /api/pilot/tunnel to the pilot handler (rejects non-pilot bearer before path-based dispatch)', async () => {
// A plain session cookie is a valid *user* JWT but not a pilot JWT. The
// pilot handler runs first (before the shared cookie/Bearer auth) and