Files
sencho/backend/src/__tests__/proxy-cross-node-rbac-gate.test.ts
T
Anso a7144d4e71 fix: probe remote RBAC capability live and enforce exact stop-result membership (#1510)
The cross-node capability gate cached its verdict, so a remote replaced by older
code at the same URL stayed trusted until the cache expired, reopening the
non-admin HTTP escalation and the over-broad stop. The probe now hits the
remote's live /api/meta on every gated action (concurrent calls deduped, never
cached across requests, fail-closed), so a downgraded remote is detected
immediately.

Two stop-result gaps are also closed:

- A remote stop result must now cover exactly the confirmed stacks (one per
  stack, no extras, no omissions), not merely exclude extras, so a dropped
  confirmed stack is no longer accepted as clean. runLocalLabelStop reports one
  result per confirmed stack even when the label has vanished, so a current
  remote always satisfies the check.
- The local stop exception path now reports the full confirmed set, so a
  confirmed stack that lost its label is not dropped when the local stop throws.
2026-06-28 20:16:45 -04:00

124 lines
5.1 KiB
TypeScript

/**
* Mixed-version HTTP proxy gate. A remote that does not advertise the
* cross-node-rbac capability ignores the forwarded actor role and runs a
* proxied request as admin, so the hub must refuse to proxy a non-admin user's
* request to such a remote. Admins are unaffected. Each remote is a small
* loopback server that answers /api/meta with or without the capability.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'http';
import bcrypt from 'bcrypt';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let viewerBearer: string;
let adminBearer: string;
let capServer: http.Server;
let noCapServer: http.Server;
let capNodeId: number;
let noCapNodeId: number;
const PROXIED_PATH = '/api/stacks';
function metaServer(capabilities: string[], seen?: string[]): http.Server {
return http.createServer((req, res) => {
if (seen && req.url) seen.push(req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
if (req.url?.startsWith('/api/meta')) {
res.end(JSON.stringify({ version: '0.93.0', capabilities }));
} else {
// Any proxied request that clears the gate lands here.
res.end('[]');
}
});
}
const noCapPaths: string[] = [];
async function listen(server: http.Server): Promise<number> {
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return (server.address() as import('net').AddressInfo).port;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const { DatabaseService } = await import('../services/DatabaseService');
const db = DatabaseService.getInstance();
const hash = await bcrypt.hash('password123', 1);
db.addUser({ username: 'gate-viewer', password_hash: hash, role: 'viewer' });
const viewer = db.getUserByUsername('gate-viewer')!;
viewerBearer = jwt.sign({ username: 'gate-viewer', role: 'viewer', tv: viewer.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
adminBearer = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
capServer = metaServer(['cross-node-rbac']);
noCapServer = metaServer(['fleet'], noCapPaths); // older remote: no cross-node-rbac
const capPort = await listen(capServer);
const noCapPort = await listen(noCapServer);
capNodeId = db.addNode({
name: 'cap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${capPort}`, api_token: 'cap-token',
});
noCapNodeId = db.addNode({
name: 'nocap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp',
is_default: false, api_url: `http://127.0.0.1:${noCapPort}`, api_token: 'nocap-token',
});
});
afterAll(async () => {
await new Promise<void>((resolve) => capServer.close(() => resolve()));
await new Promise<void>((resolve) => noCapServer.close(() => resolve()));
cleanupTestDb(tmpDir);
});
describe('remote proxy cross-node-rbac gate', () => {
it('denies a non-admin proxied request to a remote that lacks cross-node-rbac', async () => {
const res = await request(app)
.get(PROXIED_PATH)
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/upgrade/i);
});
it('allows a non-admin proxied request to a remote that advertises cross-node-rbac', async () => {
const res = await request(app)
.get(PROXIED_PATH)
.set('Authorization', `Bearer ${viewerBearer}`)
.set('x-node-id', String(capNodeId));
// Forwarded to the capability-advertising remote, which answers 200.
expect(res.status).not.toBe(403);
});
it('does not gate an admin proxied request even to a remote lacking cross-node-rbac', async () => {
const res = await request(app)
.get(PROXIED_PATH)
.set('Authorization', `Bearer ${adminBearer}`)
.set('x-node-id', String(noCapNodeId));
expect(res.status).not.toBe(403);
});
it('refuses a real stop to a remote lacking cross-node-rbac via the live probe, never contacting its local-stop receiver', async () => {
// Exercises the fleet-stop gate end to end through the REAL capability
// helper (not a mock): the live /api/meta probe of the no-cap remote returns
// no capability, so the stop is refused and the destructive receiver on that
// remote is never contacted.
noCapPaths.length = 0;
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', `Bearer ${adminBearer}`)
.send({ labelName: 'any-label', targets: [{ nodeId: noCapNodeId, stackNames: ['x'] }] });
expect(res.status).toBe(200);
const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === noCapNodeId);
expect(node.reachable).toBe(false);
expect(node.error).toMatch(/upgrade/i);
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true);
expect(noCapPaths.some(p => p.includes('/api/fleet-actions/labels/local-stop'))).toBe(false);
});
});