mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
fix(security): harden authentication and outbound targets (#1877)
* fix(security): harden auth and outbound targets * fix(security): prevent login lockout and honor trusted schemes
This commit is contained in:
@@ -399,6 +399,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://remote:1852',
|
||||
apiToken: 'tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
@@ -434,6 +435,7 @@ describe('AutoHealService.evaluate', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://remote2:1852',
|
||||
apiToken: 'tok2',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ ok: true } as Response);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ beforeEach(() => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
|
||||
@@ -33,8 +33,16 @@ describe('fetchRemoteMeta Authorization header', () => {
|
||||
await fetchRemoteMeta('https://remote.example.com:1852', 'real-token');
|
||||
|
||||
expect(getSpy).toHaveBeenCalledTimes(1);
|
||||
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
const init = getSpy.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
proxy?: boolean;
|
||||
httpAgent?: unknown;
|
||||
httpsAgent?: unknown;
|
||||
};
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
|
||||
expect(init.proxy).toBe(false);
|
||||
expect(init.httpAgent).toBeDefined();
|
||||
expect(init.httpsAgent).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits Authorization entirely when token is empty (pilot-agent loopback)', async () => {
|
||||
@@ -42,18 +50,21 @@ describe('fetchRemoteMeta Authorization header', () => {
|
||||
data: { version: '0.76.7', capabilities: ['stacks'], startedAt: 1, updateError: null },
|
||||
});
|
||||
|
||||
await fetchRemoteMeta('http://127.0.0.1:54321', '');
|
||||
await fetchRemoteMeta('http://127.0.0.1:54321', '', true);
|
||||
|
||||
expect(getSpy).toHaveBeenCalledTimes(1);
|
||||
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
const init = getSpy.mock.calls[0][1] as { headers: Record<string, string>; proxy?: boolean };
|
||||
expect(init.headers).toEqual({});
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
expect(init.proxy).toBe(false);
|
||||
expect(init).not.toHaveProperty('httpAgent');
|
||||
expect(init).not.toHaveProperty('httpsAgent');
|
||||
});
|
||||
|
||||
it('returns OFFLINE_META shape on transport failure', async () => {
|
||||
vi.spyOn(axios, 'get').mockRejectedValue(new Error('connect ECONNREFUSED'));
|
||||
|
||||
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '');
|
||||
const meta = await fetchRemoteMeta('http://127.0.0.1:54321', '', true);
|
||||
|
||||
expect(meta).toEqual({
|
||||
version: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -100,4 +101,35 @@ describe('GET /api/diagnostics/environment', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/diagnostics/environment')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.set('Host', 'sencho.example.com')
|
||||
.set('X-Forwarded-Proto', 'https');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls');
|
||||
expect(tls?.status).toBe('warn');
|
||||
});
|
||||
|
||||
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
try {
|
||||
const res = await request(app)
|
||||
.get('/api/diagnostics/environment')
|
||||
.set('Authorization', adminAuthHeader)
|
||||
.set('Host', 'sencho.example.com')
|
||||
.set('X-Forwarded-Proto', 'https');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const tls = (res.body.checks as Array<{ id: string; status: string }>).find(check => check.id === 'tls');
|
||||
expect(tls?.status).toBe('pass');
|
||||
} finally {
|
||||
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
|
||||
resetTrustedProxyBlockListCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -786,7 +786,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
|
||||
const remoteId = addRemote('assign-remote-ok');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
@@ -814,7 +814,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
|
||||
const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '', trustedLoopback: true });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
@@ -850,7 +850,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-404');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));
|
||||
|
||||
const res = await request(app)
|
||||
@@ -867,7 +867,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('reports a transport failure as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-transport');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const res = await request(app)
|
||||
@@ -883,7 +883,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('reports a malformed 200 body as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-malformed');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: 'not-an-array' }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
@@ -903,7 +903,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('fails a node whose 200 body returns an empty results array for a non-empty request', async () => {
|
||||
const remoteId = addRemote('assign-remote-empty');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
// A well-shaped { created, results } body whose results are empty used to
|
||||
// pass the bare Array.isArray check and read as a successful zero-stack
|
||||
// assign. Membership validation must fail the node instead.
|
||||
@@ -926,7 +926,7 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
|
||||
it('fails a node whose results omit one of the requested stacks', async () => {
|
||||
const remoteId = addRemote('assign-remote-partial');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
|
||||
// Two stacks requested, only one row returned: a partial body the control
|
||||
// must not accept as a clean assign of the covered stack alone.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
|
||||
@@ -66,7 +66,7 @@ afterEach(() => {
|
||||
|
||||
function mockTargetActive() {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
||||
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
|
||||
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true };
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,8 +90,8 @@ afterEach(() => {
|
||||
|
||||
function mockTargets(opts: { pilotReachable: boolean; proxyReachable: boolean } = { pilotReachable: true, proxyReachable: true }) {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
||||
if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '' } : null;
|
||||
if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN } : null;
|
||||
if (id === pilotNodeId) return opts.pilotReachable ? { apiUrl: PILOT_LOOPBACK, apiToken: '', trustedLoopback: true } : null;
|
||||
if (id === proxyNodeId) return opts.proxyReachable ? { apiUrl: PROXY_URL, apiToken: PROXY_TOKEN, trustedLoopback: false } : null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ afterEach(() => {
|
||||
|
||||
function mockTargetForPilot() {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => {
|
||||
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '' };
|
||||
if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' };
|
||||
if (id === pilotNodeId) return { apiUrl: LOOPBACK, apiToken: '', trustedLoopback: true };
|
||||
if (id === proxyNodeId) return { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false };
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -627,7 +627,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
|
||||
|
||||
it('writes notes to a remote node via the proxy dossier PUT when opted in', async () => {
|
||||
const { id, remoteId } = remoteDocSnapshot('rweb', 'documented');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
|
||||
const calls: Array<{ url: string; method?: string }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, opts?: { method?: string }) => {
|
||||
calls.push({ url, method: opts?.method });
|
||||
@@ -647,7 +647,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
|
||||
|
||||
it('reports a non-fatal notesError when the remote dossier PUT fails but files restored', async () => {
|
||||
const { id, remoteId } = remoteDocSnapshot('rweb2', 'documented');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
|
||||
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
|
||||
@@ -668,7 +668,7 @@ describe('Snapshot restore: remote dossier notes (proxy PUT)', () => {
|
||||
// restore-all is driven by snapshot id; the target node is resolved from
|
||||
// the snapshot's stored files, so the returned remoteId is not needed here.
|
||||
const { id } = remoteDocSnapshot('rweb3', 'documented');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok', trustedLoopback: false });
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
if (/\/dossier$/.test(url)) return { ok: false, status: 500, text: async () => 'boom' } as unknown as Response;
|
||||
return { ok: true, status: 200, text: async () => '' } as unknown as Response;
|
||||
@@ -928,6 +928,7 @@ describe('Snapshot restore: recovery generation contract', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://remote:1852',
|
||||
apiToken: 'tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function mockMeta(meta: RemoteMeta) {
|
||||
|
||||
function mockTarget() {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) =>
|
||||
id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token' } : null,
|
||||
id === proxyNodeId ? { apiUrl: 'http://192.168.1.99:1852', apiToken: 'proxy-token', trustedLoopback: false } : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||
import { insertHistory } from '../services/gitops/history';
|
||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
||||
import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
/** A minimal live Direct application row for GitOps read-path fixtures. */
|
||||
function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow {
|
||||
@@ -219,6 +220,20 @@ describe('POST /api/git-sources/browse: URL validation', () => {
|
||||
expect(listRepoTree).not.toHaveBeenCalled();
|
||||
listRepoTree.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects repository hosts that resolve to an unsafe address', async () => {
|
||||
const res = await withLoopbackTargetProtection(() => request(app)
|
||||
.post('/api/git-sources/browse')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({
|
||||
repo_url: 'https://127.0.0.1:9999/repo.git',
|
||||
branch: 'main',
|
||||
auth_type: 'none',
|
||||
}));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not allowed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/stacks/:stackName/git-source — max-length caps', () => {
|
||||
@@ -1757,6 +1772,15 @@ describe('POST /api/git-sources/ssh-host-key', () => {
|
||||
expect(res.body.error).toMatch(/SSH repository URL/i);
|
||||
});
|
||||
|
||||
it('rejects host-key probes to an unsafe address', async () => {
|
||||
const res = await withLoopbackTargetProtection(() => request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'ssh://git@127.0.0.1:22/example/repo.git' }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not allowed/i);
|
||||
});
|
||||
|
||||
it('returns scanned host keys for an SSH repository URL', async () => {
|
||||
const scanHostKeys = vi.spyOn(
|
||||
await import('../services/git/sshTrust'),
|
||||
@@ -1771,12 +1795,13 @@ describe('POST /api/git-sources/ssh-host-key', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'git@github.com:example/repo.git' });
|
||||
.send({ repo_url: 'git@pinned.example:example/repo.git' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.host).toBe('github.com');
|
||||
expect(res.body.host).toBe('pinned.example');
|
||||
expect(res.body.port).toBe(22);
|
||||
expect(res.body.keys).toHaveLength(1);
|
||||
expect(res.body.keys[0].fingerprint).toBe('SHA256:fixtureFingerprint');
|
||||
expect(scanHostKeys).toHaveBeenCalledWith('pinned.example', 22, '93.184.216.34');
|
||||
scanHostKeys.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1822,7 +1847,7 @@ describe('POST /api/git-sources/ssh-host-key', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/git-sources/ssh-host-key')
|
||||
.set('Authorization', `Bearer ${adminToken()}`)
|
||||
.send({ repo_url: 'ssh://git@git.example.com:2222/org/repo.git' });
|
||||
.send({ repo_url: 'ssh://git@github.com:2222/org/repo.git' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/Git source operation failed/i);
|
||||
scanHostKeys.mockRestore();
|
||||
|
||||
@@ -165,7 +165,7 @@ async function startSshGitServer(bareDir: string, port: number): Promise<Omit<Ss
|
||||
throw new Error(`sshd did not come up on port ${port}: ${stderr.trim() || '(no stderr)'}`, { cause: e });
|
||||
}
|
||||
|
||||
const scanned = await scanHostKeys('127.0.0.1', port);
|
||||
const scanned = await scanHostKeys('127.0.0.1', port, '127.0.0.1');
|
||||
assertFixtureHostKey(port, hostKey.publicLine, scanned);
|
||||
const knownHostsEntry = scanned.map((k) => k.line).join('\n');
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from '../services/git/credentialHelper';
|
||||
import * as gitBinary from '../services/git/gitBinary';
|
||||
import { nativeGitTransport, REF_MAX_LEN, startSizeWatchdog, verifyFastForward } from '../services/git/nativeGitTransport';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
import { GIT_ALLOWED_HOST_ENV_VAR } from '../services/git/credentialHelper';
|
||||
|
||||
const GIT_EXEC_PATH_STUB = 'C:/Program Files/Git/mingw64/libexec/git-core';
|
||||
@@ -187,6 +188,7 @@ describe('classifyGitFailure (native git stderr corpus)', () => {
|
||||
['size', { transportFailure: true as const, reason: 'size', maxBytes: 5 * 1024 * 1024, host: 'h', hasToken: false }, 'Repository exceeds the maximum clone size of 5 MB.'],
|
||||
['tip-changed', { transportFailure: true as const, reason: 'tip-changed', host: 'h', hasToken: false }, 'Repository tip changed during fetch; retry the pull.'],
|
||||
['ref-not-found', { transportFailure: true as const, reason: 'ref-not-found', host: 'h', hasToken: false }, 'The configured branch, tag, or commit was not found in the repository.'],
|
||||
['ssh-auth-required', { transportFailure: true as const, reason: 'ssh-auth-required', host: 'h', hasToken: false }, 'SSH repository URLs require a deploy key.'],
|
||||
['unsupported-ref', { transportFailure: true as const, reason: 'unsupported-ref', host: 'h', hasToken: false }, 'The configured commit is not reachable on this repository host. Use a branch or tag, or a commit the host advertises.'],
|
||||
['timeout', { transportFailure: true as const, reason: 'timeout', host: 'github.com', hasToken: false }, 'Timed out reaching github.com.'],
|
||||
] as const)('maps structured reason %s verbatim', (_label, failure, message) => {
|
||||
@@ -560,6 +562,15 @@ describe('transport argv hardening', () => {
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects SSH repository URLs without deploy-key authentication before spawning git', async () => {
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl: 'ssh://git@ssh.example/org/repo.git',
|
||||
ref: 'main',
|
||||
workspaceRoot: os.tmpdir(),
|
||||
})).rejects.toMatchObject({ transportFailure: true as const, reason: 'ssh-auth-required' });
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects option-injecting ref names', async () => {
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
@@ -799,20 +810,53 @@ describe('resolve/fetch/verify flow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves an annotated tag through the peeled ^{} commit', async () => {
|
||||
it('pins HTTPS to the validated address while resolving an annotated tag', async () => {
|
||||
scriptSpawn([{
|
||||
stdout: `${SHA_B}\trefs/tags/v1\n${SHA_A}\trefs/tags/v1^{}\n`,
|
||||
}]);
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
repoUrl: 'https://pinned.example:8443/example/repo.git',
|
||||
ref: 'v1',
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'tag' });
|
||||
const lsRemoteArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(lsRemoteArgs).toContain('refs/tags/v1^{}');
|
||||
expect(lsRemoteArgs).toContain('http.followRedirects=false');
|
||||
expect(lsRemoteArgs).toContain('http.proxy=');
|
||||
expect(lsRemoteArgs).toContain('http.curloptResolve=pinned.example:8443:93.184.216.34');
|
||||
const env = spawnEnv(0);
|
||||
expect(env.HTTP_PROXY).toBe('');
|
||||
expect(env.HTTPS_PROXY).toBe('');
|
||||
expect(env.ALL_PROXY).toBe('');
|
||||
expect(env.http_proxy).toBe('');
|
||||
expect(env.https_proxy).toBe('');
|
||||
expect(env.all_proxy).toBe('');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('pins SSH to the validated address while retaining host identity and port', async () => {
|
||||
scriptSpawn([{ stdout: `${SHA_A}\trefs/heads/main\n` }]);
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl: 'ssh://git@pinned.example:2222/example/repo.git',
|
||||
ref: 'main',
|
||||
sshAuth: {
|
||||
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nYWJj\n-----END OPENSSH PRIVATE KEY-----\n',
|
||||
knownHostsEntry: 'pinned.example ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q=\n',
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'branch' });
|
||||
|
||||
const sshCommand = spawnEnv(0).GIT_SSH_COMMAND;
|
||||
expect(sshCommand).toContain('Hostname=93.184.216.34');
|
||||
expect(sshCommand).toContain('HostKeyAlias=[pinned.example]:2222');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -836,12 +880,12 @@ describe('resolve/fetch/verify flow', () => {
|
||||
it('self-resolves a full SHA without a network round trip', async () => {
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await expect(nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
await expect(withLoopbackTargetProtection(() => nativeGitTransport.resolveRef({
|
||||
repoUrl: 'https://127.0.0.1/example/repo.git',
|
||||
ref: SHA_A.toUpperCase(),
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
})).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' });
|
||||
}))).resolves.toMatchObject({ commitSha: SHA_A, kind: 'sha' });
|
||||
// The SHA needs no ls-remote: the identity IS the value.
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -1061,6 +1105,26 @@ describe('clone failure classification and final size gate', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsafe targets during fast-forward verification', async () => {
|
||||
const root = await makeWorkspace();
|
||||
try {
|
||||
await expect(withLoopbackTargetProtection(() => verifyFastForward({
|
||||
repoUrl: 'https://127.0.0.1/repo.git',
|
||||
ancestorSha: SHA_B,
|
||||
descendantSha: SHA_A,
|
||||
timeoutMs: 5000,
|
||||
workspaceRoot: root,
|
||||
maxBytes: 100 * 1024 * 1024,
|
||||
}))).rejects.toMatchObject({
|
||||
transportFailure: true as const,
|
||||
reason: 'unsafe-target',
|
||||
});
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('deepens history exponentially with a bounded number of remote fetches', async () => {
|
||||
scriptSpawn([
|
||||
{ code: 0 },
|
||||
@@ -1411,8 +1475,7 @@ describe('clone failure classification and final size gate', () => {
|
||||
// simulated confirmation has not arrived yet: settling here
|
||||
// would let a caller start cleaning up the workspace while the
|
||||
// child tree is still alive.
|
||||
await new Promise((r) => setTimeout(r, 55));
|
||||
expect(killInvoked).toBe(true);
|
||||
await vi.waitFor(() => expect(killInvoked).toBe(true), { timeout: 250 });
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ transportFailure: true as const, reason: 'timeout' });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { vi } from 'vitest';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import type { LookupFunction } from 'net';
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
const targetProtectionScope = new AsyncLocalStorage<boolean>();
|
||||
|
||||
export async function withLoopbackTargetProtection<T>(action: () => PromiseLike<T>): Promise<T> {
|
||||
return targetProtectionScope.run(false, async () => action());
|
||||
}
|
||||
|
||||
vi.mock('../../utils/outboundTarget', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils/outboundTarget')>();
|
||||
const fixtureAllows = (hostname: string): boolean => {
|
||||
if (targetProtectionScope.getStore() === false) return false;
|
||||
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
return normalized === 'localhost'
|
||||
|| normalized === '::1'
|
||||
|| normalized.startsWith('127.')
|
||||
|| normalized.startsWith('10.')
|
||||
|| normalized.startsWith('192.168.')
|
||||
|| /^172\.(1[6-9]|2\d|3[01])\./.test(normalized)
|
||||
|| normalized.endsWith('.example.com')
|
||||
|| normalized.endsWith('.example')
|
||||
|| normalized.endsWith('.invalid')
|
||||
|| normalized.endsWith('.local')
|
||||
|| normalized === 'remote'
|
||||
|| normalized === 'remote2'
|
||||
|| normalized === 'good-host'
|
||||
|| normalized === 'bad-host';
|
||||
};
|
||||
const fixtureAddress = (hostname: string): string => {
|
||||
const normalized = hostname.replace(/^\[|\]$/g, '');
|
||||
if (normalized === 'localhost') return '127.0.0.1';
|
||||
if (normalized === 'pinned.example') return '93.184.216.34';
|
||||
return normalized;
|
||||
};
|
||||
const fixtureLookup: LookupFunction = (hostname, options, callback): void => {
|
||||
if (!fixtureAllows(hostname)) {
|
||||
actual.safeOutboundLookup(hostname, options, callback);
|
||||
return;
|
||||
}
|
||||
const normalized = hostname.replace(/^\[|\]$/g, '');
|
||||
const address = fixtureAddress(hostname);
|
||||
const family = normalized === '::1' ? 6 : 4;
|
||||
if (options.all) callback(null, [{ address, family }]);
|
||||
else callback(null, address, family);
|
||||
};
|
||||
|
||||
const fixtureHttpAgent = new http.Agent({ lookup: fixtureLookup });
|
||||
const fixtureHttpsAgent = new https.Agent({ lookup: fixtureLookup });
|
||||
|
||||
return {
|
||||
...actual,
|
||||
assertSafeOutboundHostname: async (
|
||||
hostname: string,
|
||||
): Promise<void> => {
|
||||
if (fixtureAllows(hostname)) return;
|
||||
await actual.assertSafeOutboundHostname(hostname);
|
||||
},
|
||||
assertSafeOutboundUrl: async (
|
||||
raw: string,
|
||||
): Promise<URL> => {
|
||||
const url = new URL(raw);
|
||||
if (fixtureAllows(url.hostname)) return url;
|
||||
return actual.assertSafeOutboundUrl(raw);
|
||||
},
|
||||
resolveSafeOutboundHostname: async (hostname: string) => {
|
||||
if (fixtureAllows(hostname)) {
|
||||
const normalized = hostname.replace(/^\[|\]$/g, '');
|
||||
return [{ address: fixtureAddress(hostname), family: normalized === '::1' ? 6 : 4 }];
|
||||
}
|
||||
return actual.resolveSafeOutboundHostname(hostname);
|
||||
},
|
||||
safeOutboundLookup: fixtureLookup,
|
||||
safeHttpAgent: fixtureHttpAgent,
|
||||
safeHttpsAgent: fixtureHttpsAgent,
|
||||
safeAxiosTransport: (trustedLoopback = false) => ({
|
||||
maxRedirects: 0,
|
||||
proxy: false,
|
||||
...(trustedLoopback ? {} : { httpAgent: fixtureHttpAgent, httpsAgent: fixtureHttpsAgent }),
|
||||
}),
|
||||
safeRemoteFetch: async (
|
||||
input: Parameters<typeof actual.safeRemoteFetch>[0],
|
||||
init?: Parameters<typeof actual.safeRemoteFetch>[1],
|
||||
trustedLoopback?: boolean,
|
||||
) => {
|
||||
const url = input instanceof URL
|
||||
? input
|
||||
: new URL(typeof input === 'string' ? input : input.url);
|
||||
if (fixtureAllows(url.hostname)) {
|
||||
return globalThis.fetch(input, { ...init, redirect: 'error' });
|
||||
}
|
||||
return actual.safeRemoteFetch(input, init, trustedLoopback);
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -493,7 +493,7 @@ describe('GET /api/fleet/container-labels', () => {
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
@@ -511,7 +511,7 @@ describe('GET /api/fleet/container-labels', () => {
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
|
||||
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
@@ -528,7 +528,7 @@ describe('GET /api/fleet/container-labels', () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
@@ -544,7 +544,7 @@ describe('GET /api/fleet/container-labels', () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
@@ -560,7 +560,7 @@ describe('GET /api/fleet/container-labels', () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
cleanupTestDb,
|
||||
setupTestDb,
|
||||
TEST_PASSWORD,
|
||||
TEST_USERNAME,
|
||||
} from './helpers/setupTestDb';
|
||||
|
||||
let authRouter: typeof import('../routes/auth').authRouter;
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.resetModules();
|
||||
tmpDir = await setupTestDb();
|
||||
({ authRouter } = await import('../routes/auth'));
|
||||
|
||||
app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.use(express.json());
|
||||
app.use('/api/auth', authRouter);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('production login rate limiter', () => {
|
||||
it('blocks repeated attempts from one source', async () => {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const failure = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.51.100.10')
|
||||
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
||||
expect(failure.status).toBe(401);
|
||||
}
|
||||
|
||||
const blocked = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.51.100.10')
|
||||
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
||||
|
||||
expect(blocked.status).toBe(429);
|
||||
});
|
||||
|
||||
it('allows valid credentials after failures from other sources', async () => {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const failure = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', `203.0.113.${attempt + 1}`)
|
||||
.send({ username: TEST_USERNAME, password: 'wrong-password' });
|
||||
expect(failure.status).toBe(401);
|
||||
}
|
||||
|
||||
const validLogin = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '203.0.113.250')
|
||||
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
|
||||
|
||||
expect(validLogin.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
@@ -114,6 +115,7 @@ describe('MeshService.inspectStackServices dispatch (C-3 fix)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 }));
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
@@ -112,6 +113,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Internal Server Error', { status: 500 }));
|
||||
|
||||
@@ -160,6 +162,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ stacks: ['ok-string', 42, null, { not: 'a string' }] }),
|
||||
@@ -223,6 +226,7 @@ describe('MeshService.listStacksOnNode dispatch (F8)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
|
||||
throw new MeshError('push_failed', 'simulated transport failure');
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
@@ -74,6 +75,28 @@ describe('MeshProxyTunnelDialer', () => {
|
||||
expect(dialer.hasBridge(nodeId)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unsafe proxy target before opening a WebSocket', async () => {
|
||||
const dialer = MeshProxyTunnelDialer.resetForTest(0);
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.addNode({
|
||||
name: 'proxy-test-unsafe-target',
|
||||
type: 'remote',
|
||||
compose_dir: '',
|
||||
is_default: false,
|
||||
mode: 'proxy',
|
||||
api_url: 'http://127.0.0.1:1852',
|
||||
api_token: 'test-token',
|
||||
});
|
||||
|
||||
const result = await withLoopbackTargetProtection(() => dialer.ensureBridge(nodeId));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(dialer.getRecentFailure(nodeId)).toMatchObject({
|
||||
code: 'network_error',
|
||||
message: 'The target address is not allowed.',
|
||||
});
|
||||
});
|
||||
|
||||
it('expires the recent-failure cache entry after the cache TTL window', async () => {
|
||||
const dialer = MeshProxyTunnelDialer.resetForTest(0);
|
||||
const result = await dialer.ensureBridge(8888);
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
@@ -82,6 +83,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
@@ -112,6 +114,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
@@ -139,6 +142,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => {
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'remote-tok',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('another operation is already in progress', { status: 500 }),
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('networking summary', () => {
|
||||
it('degrades a remote that errors to a node-error while keeping the hub', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-degrade', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't', trustedLoopback: false } : null);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/networking-summary').set('Authorization', authHeader);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { WebSocket } from 'ws';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { PilotTunnelManager } from '../services/PilotTunnelManager';
|
||||
import { MeshProxyTunnelDialer } from '../services/MeshProxyTunnelDialer';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
@@ -146,6 +147,45 @@ describe('node-management write routes require node:manage', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.getNode(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects proxy nodes whose API URL resolves to an unsafe address', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Authorization', `Bearer ${tokenForRole('admin')}`)
|
||||
.send({
|
||||
name: `nm-unsafe-${Date.now()}`,
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
api_url: 'http://[::ffff:127.0.0.1]:1852',
|
||||
api_token: 'test-token',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects updating a proxy node to an unsafe API URL', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const id = db.addNode({
|
||||
name: `nm-update-unsafe-${Date.now()}`,
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/tmp/x',
|
||||
is_default: false,
|
||||
api_url: 'https://remote.example.com:1852',
|
||||
api_token: 'test-token',
|
||||
});
|
||||
|
||||
const res = await withLoopbackTargetProtection(() => request(app)
|
||||
.put(`/api/nodes/${id}`)
|
||||
.set('Authorization', `Bearer ${tokenForRole('admin')}`)
|
||||
.send({ api_url: 'http://127.0.0.1:1852' }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not allowed/i);
|
||||
expect(db.getNode(id)?.api_url).toBe('https://remote.example.com:1852');
|
||||
db.deleteNode(id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting a node tears down its tunnel or mesh bridge', () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
||||
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'http://127.0.0.1:54321',
|
||||
apiToken: '',
|
||||
trustedLoopback: true,
|
||||
});
|
||||
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: {
|
||||
@@ -96,8 +97,10 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
||||
expect(axiosSpy).toHaveBeenCalledTimes(1);
|
||||
const url = axiosSpy.mock.calls[0][0];
|
||||
expect(url).toBe('http://127.0.0.1:54321/api/meta');
|
||||
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string>; httpAgent?: unknown; httpsAgent?: unknown };
|
||||
expect(init.headers).toEqual({});
|
||||
expect(init.httpAgent).toBeUndefined();
|
||||
expect(init.httpsAgent).toBeUndefined();
|
||||
|
||||
db.deleteNode(nodeId);
|
||||
});
|
||||
@@ -118,6 +121,7 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
||||
vi.spyOn(reg, 'getProxyTarget').mockReturnValue({
|
||||
apiUrl: 'https://remote.example.com:1852',
|
||||
apiToken: 'real-token',
|
||||
trustedLoopback: false,
|
||||
});
|
||||
const axiosSpy = vi.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { version: '0.76.7', capabilities: [], startedAt: 1, updateError: null },
|
||||
@@ -125,8 +129,10 @@ describe('NodeRegistry.fetchMetaForNode', () => {
|
||||
|
||||
await reg.fetchMetaForNode(nodeId);
|
||||
|
||||
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
const init = axiosSpy.mock.calls[0][1] as { headers: Record<string, string>; httpAgent?: unknown; httpsAgent?: unknown };
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer real-token' });
|
||||
expect(init.httpAgent).toBeDefined();
|
||||
expect(init.httpsAgent).toBeDefined();
|
||||
|
||||
db.deleteNode(nodeId);
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('POST /api/nodes - api_url SSRF validation (C2 fix)', () => {
|
||||
.set('Authorization', authHeader)
|
||||
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/loopback/i);
|
||||
expect(res.body.error).toMatch(/not allowed/i);
|
||||
});
|
||||
|
||||
it('rejects 127.0.0.1 api_url', async () => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import http from 'http';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
assertSafeOutboundUrl,
|
||||
isBlockedOutboundAddress,
|
||||
safeOutboundLookup,
|
||||
safeAxiosTransport,
|
||||
safeRemoteFetch,
|
||||
UnsafeOutboundTargetError,
|
||||
} from '../utils/outboundTarget';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('outbound target validation', () => {
|
||||
it.each([
|
||||
'127.0.0.1',
|
||||
'169.254.169.254',
|
||||
'100.100.100.200',
|
||||
'192.0.2.10',
|
||||
'198.18.0.10',
|
||||
'198.51.100.10',
|
||||
'203.0.113.10',
|
||||
'0.0.0.0',
|
||||
'224.0.0.1',
|
||||
'::1',
|
||||
'fe80::1',
|
||||
'ff02::1',
|
||||
'100::1',
|
||||
'2001:db8::1',
|
||||
'fd00:ec2::254',
|
||||
'::ffff:7f00:1',
|
||||
])('blocks unsafe address %s', (address) => {
|
||||
expect(isBlockedOutboundAddress(address)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'10.0.0.10',
|
||||
'172.16.0.10',
|
||||
'192.168.1.10',
|
||||
'fd12:3456:789a::10',
|
||||
'8.8.8.8',
|
||||
'::ffff:808:808',
|
||||
])('allows private and ordinary unicast address %s', (address) => {
|
||||
expect(isBlockedOutboundAddress(address)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a URL whose literal host is unsafe', async () => {
|
||||
await expect(withLoopbackTargetProtection(() =>
|
||||
assertSafeOutboundUrl('https://[::ffff:127.0.0.1]/repo.git')))
|
||||
.rejects.toBeInstanceOf(UnsafeOutboundTargetError);
|
||||
});
|
||||
|
||||
it('accepts private LAN targets', async () => {
|
||||
await expect(assertSafeOutboundUrl('http://192.168.1.50:1852'))
|
||||
.resolves.toMatchObject({ hostname: '192.168.1.50' });
|
||||
});
|
||||
|
||||
it('allows only loopback targets when the E2E test override is active', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true');
|
||||
|
||||
await expect(actual.resolveSafeOutboundHostname('127.0.0.1'))
|
||||
.resolves.toEqual([{ address: '127.0.0.1', family: 4 }]);
|
||||
await expect(actual.resolveSafeOutboundHostname('169.254.169.254'))
|
||||
.rejects.toMatchObject({ reason: 'blocked' });
|
||||
});
|
||||
|
||||
it('ignores the E2E loopback override outside the test environment', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('SENCHO_E2E_ALLOW_LOOPBACK_OUTBOUND', 'true');
|
||||
|
||||
await expect(actual.resolveSafeOutboundHostname('127.0.0.1'))
|
||||
.rejects.toMatchObject({ reason: 'blocked' });
|
||||
});
|
||||
|
||||
it('rejects an unsafe address inside the connection lookup', async () => {
|
||||
const error = await withLoopbackTargetProtection(() =>
|
||||
new Promise<NodeJS.ErrnoException | null>((resolve) => {
|
||||
safeOutboundLookup('127.0.0.1', {}, (lookupError) => resolve(lookupError));
|
||||
}));
|
||||
|
||||
expect(error).toBeInstanceOf(UnsafeOutboundTargetError);
|
||||
});
|
||||
|
||||
it('rejects a hostname when any resolved address is unsafe', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
const resolveMixed = async () => [
|
||||
{ address: '93.184.216.34', family: 4 as const },
|
||||
{ address: '169.254.169.254', family: 4 as const },
|
||||
];
|
||||
|
||||
await expect(actual.resolveSafeOutboundHostname('mixed.example', resolveMixed))
|
||||
.rejects.toMatchObject({ reason: 'blocked' });
|
||||
});
|
||||
|
||||
it('rejects an exact metadata address returned by DNS', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
const resolveMetadata = async () => [
|
||||
{ address: '100.100.100.200', family: 4 as const },
|
||||
];
|
||||
|
||||
await expect(actual.resolveSafeOutboundHostname('metadata.example', resolveMetadata))
|
||||
.rejects.toMatchObject({ reason: 'blocked' });
|
||||
});
|
||||
|
||||
it('rechecks DNS at connection time after safe validation', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
const resolveSafe = async () => [{ address: '93.184.216.34', family: 4 as const }];
|
||||
await expect(actual.resolveSafeOutboundHostname('rebinding.example', resolveSafe)).resolves.toHaveLength(1);
|
||||
|
||||
const rebindingLookup = actual.createSafeOutboundLookup((_hostname, _options, callback) => {
|
||||
callback(null, [{ address: '169.254.169.254', family: 4 }]);
|
||||
});
|
||||
const connectionError = await new Promise<NodeJS.ErrnoException | null>((resolve) => {
|
||||
rebindingLookup('rebinding.example', {}, (lookupError) => resolve(lookupError));
|
||||
});
|
||||
expect(connectionError).toMatchObject({ reason: 'blocked' });
|
||||
});
|
||||
|
||||
it('rejects unsafe addresses at fetch connection time', async () => {
|
||||
await expect(withLoopbackTargetProtection(() =>
|
||||
safeRemoteFetch('http://127.0.0.1:1852/api/meta')))
|
||||
.rejects.toBeInstanceOf(UnsafeOutboundTargetError);
|
||||
});
|
||||
|
||||
it('rejects redirects without contacting the redirect destination', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/outboundTarget')>('../utils/outboundTarget');
|
||||
let destinationRequests = 0;
|
||||
const destination = http.createServer((_req, res) => {
|
||||
destinationRequests += 1;
|
||||
res.end('unexpected');
|
||||
});
|
||||
await new Promise<void>((resolve) => destination.listen(0, '127.0.0.1', resolve));
|
||||
const destinationAddress = destination.address();
|
||||
if (!destinationAddress || typeof destinationAddress === 'string') throw new Error('Missing destination address');
|
||||
|
||||
const redirect = http.createServer((_req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${destinationAddress.port}/target` });
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => redirect.listen(0, '127.0.0.1', resolve));
|
||||
const redirectAddress = redirect.address();
|
||||
if (!redirectAddress || typeof redirectAddress === 'string') throw new Error('Missing redirect address');
|
||||
|
||||
try {
|
||||
await expect(actual.safeRemoteFetch(
|
||||
`http://127.0.0.1:${redirectAddress.port}/start`,
|
||||
{},
|
||||
true,
|
||||
)).rejects.toThrow();
|
||||
expect(destinationRequests).toBe(0);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve, reject) => destination.close((error) => error ? reject(error) : resolve())),
|
||||
new Promise<void>((resolve, reject) => redirect.close((error) => error ? reject(error) : resolve())),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('isolates protected loopback checks from concurrent fixture requests', async () => {
|
||||
const [ordinary, protectedResult] = await Promise.all([
|
||||
assertSafeOutboundUrl('http://127.0.0.1:1852').then(() => 'allowed'),
|
||||
withLoopbackTargetProtection(() => assertSafeOutboundUrl('http://127.0.0.1:1852'))
|
||||
.then(() => 'allowed', () => 'blocked'),
|
||||
]);
|
||||
|
||||
expect(ordinary).toBe('allowed');
|
||||
expect(protectedResult).toBe('blocked');
|
||||
});
|
||||
|
||||
it('disables environment proxy routing for guarded Axios requests', () => {
|
||||
expect(safeAxiosTransport(false)).toMatchObject({
|
||||
maxRedirects: 0,
|
||||
proxy: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import request from 'supertest';
|
||||
import crypto from 'crypto';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
|
||||
|
||||
interface ComposeService {
|
||||
image: string;
|
||||
@@ -223,6 +224,39 @@ describe('SENCHO_PUBLIC_URL override in mintPilotEnrollment', () => {
|
||||
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
});
|
||||
|
||||
it('ignores a forwarded HTTPS scheme from an untrusted peer', async () => {
|
||||
delete process.env.SENCHO_PUBLIC_URL;
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Cookie', adminCookie)
|
||||
.set('Host', 'sencho.example.com')
|
||||
.set('X-Forwarded-Proto', 'https')
|
||||
.send({ name: 'pilot-untrusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' });
|
||||
|
||||
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
|
||||
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('http://sencho.example.com');
|
||||
});
|
||||
|
||||
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
|
||||
delete process.env.SENCHO_PUBLIC_URL;
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/nodes')
|
||||
.set('Cookie', adminCookie)
|
||||
.set('Host', 'sencho.example.com')
|
||||
.set('X-Forwarded-Proto', 'https')
|
||||
.send({ name: 'pilot-trusted-forwarded-scheme', type: 'remote', mode: 'pilot_agent' });
|
||||
|
||||
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
|
||||
expect(parsed.services.agent.environment.SENCHO_PRIMARY_URL).toBe('https://sencho.example.com');
|
||||
} finally {
|
||||
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
|
||||
resetTrustedProxyBlockListCache();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to request host when env var is malformed', async () => {
|
||||
process.env.SENCHO_PUBLIC_URL = 'not a url';
|
||||
const res = await request(app)
|
||||
|
||||
@@ -17,6 +17,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
describe('remote proxy mount order', () => {
|
||||
let tmpDir: string;
|
||||
@@ -65,6 +66,16 @@ describe('remote proxy mount order', () => {
|
||||
expect(res.body?.error).toMatch(/unreachable/i);
|
||||
});
|
||||
|
||||
it('refuses an unsafe target from an existing node row', async () => {
|
||||
const res = await withLoopbackTargetProtection(() => request(app)
|
||||
.get('/api/stacks')
|
||||
.set('Authorization', authHeader)
|
||||
.set('x-node-id', String(remoteNodeId)));
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body?.error).toMatch(/not allowed/i);
|
||||
});
|
||||
|
||||
it('routes local requests (no x-node-id) to the local handler', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/stacks')
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('pilot-agent-mode proxy role header parity', () => {
|
||||
const registry = NodeRegistry.getInstance();
|
||||
const orig = registry.getProxyTarget.bind(registry);
|
||||
vi.spyOn(registry, 'getProxyTarget').mockImplementation((nid: number) => {
|
||||
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '' };
|
||||
if (nid === pilotNodeId) return { apiUrl: `http://127.0.0.1:${port}`, apiToken: '', trustedLoopback: true };
|
||||
return orig(nid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('registryDeliveryOutbound', () => {
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('registryDeliveryOutbound', () => {
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
|
||||
body,
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('registryDeliveryOutbound', () => {
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
|
||||
body: {},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('registryDeliveryOutbound', () => {
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
|
||||
body: {},
|
||||
});
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('registryDeliveryOutbound', () => {
|
||||
apiPath: '/api/stacks/demo/deploy',
|
||||
nodeId,
|
||||
node,
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token' },
|
||||
target: { apiUrl: 'http://remote:1852', apiToken: 'token', trustedLoopback: false },
|
||||
body: {},
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IncomingMessage } from 'http';
|
||||
import { Socket } from 'net';
|
||||
import { PassThrough } from 'stream';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { wsProxyServer } from '../proxy/websocketProxy';
|
||||
import { handleRemoteForwarder } from '../websocket/remoteForwarder';
|
||||
import { withLoopbackTargetProtection } from './helpers/allowLoopbackTargets';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('remote WebSocket target validation', () => {
|
||||
it('rejects an unsafe target before invoking the WebSocket proxy', async () => {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
req.url = '/api/containers/demo/logs?nodeId=2';
|
||||
req.headers.host = 'sencho.example';
|
||||
const socket = new PassThrough();
|
||||
socket.resume();
|
||||
const proxySpy = vi.spyOn(wsProxyServer, 'ws').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await withLoopbackTargetProtection(() => handleRemoteForwarder(
|
||||
req,
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
{
|
||||
pathname: '/api/containers/demo/logs',
|
||||
target: {
|
||||
apiUrl: 'http://127.0.0.1:1852',
|
||||
apiToken: 'test-token',
|
||||
trustedLoopback: false,
|
||||
},
|
||||
},
|
||||
));
|
||||
|
||||
expect(proxySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,12 @@ describe('sshTrust URL parsing', () => {
|
||||
expect(parsed?.port).toBe(2222);
|
||||
});
|
||||
|
||||
it('preserves absolute paths in default-port ssh:// URLs', () => {
|
||||
const parsed = parseSshUrl('ssh://git@host.example/abs/path/repo.git');
|
||||
expect(parsed?.href).toBe('git@host.example:/abs/path/repo.git');
|
||||
expect(parsed?.pathname).toBe('/abs/path/repo.git');
|
||||
});
|
||||
|
||||
it('classifies transport kind from mixed inputs', () => {
|
||||
expect(parseRepoTransportUrl('https://github.com/org/repo.git')?.kind).toBe('https');
|
||||
expect(parseRepoTransportUrl('git@host:org/repo.git')?.kind).toBe('ssh');
|
||||
@@ -72,11 +78,23 @@ describe('ssh credential canonicalization', () => {
|
||||
|
||||
describe('ssh command builder', () => {
|
||||
it('enforces strict host key checking', () => {
|
||||
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts');
|
||||
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', {
|
||||
address: '10.0.0.8',
|
||||
hostKeyAlias: 'git.internal.example',
|
||||
});
|
||||
expect(cmd).toContain('StrictHostKeyChecking=yes');
|
||||
expect(cmd).toContain('UserKnownHostsFile=/tmp/known_hosts');
|
||||
expect(cmd).toContain('IdentitiesOnly=yes');
|
||||
});
|
||||
|
||||
it('pins the address while retaining the repository host identity', () => {
|
||||
const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts', {
|
||||
address: '10.0.0.8',
|
||||
hostKeyAlias: '[git.internal.example]:2222',
|
||||
});
|
||||
expect(cmd).toContain('Hostname=10.0.0.8');
|
||||
expect(cmd).toContain('HostKeyAlias=[git.internal.example]:2222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH stderr classification', () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import request from 'supertest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createApp } from '../app';
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { resetTrustedProxyBlockListCache } from '../helpers/trustedProxyCidrs';
|
||||
|
||||
describe('Express trusted proxy configuration', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.SENCHO_TRUSTED_PROXY_CIDRS;
|
||||
resetTrustedProxyBlockListCache();
|
||||
});
|
||||
|
||||
it('ignores forwarded client addresses from an untrusted direct peer', async () => {
|
||||
const app = createApp();
|
||||
app.get('/peer-ip', (req, res) => res.json({ ip: req.ip }));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/peer-ip')
|
||||
.set('X-Forwarded-For', '203.0.113.50');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ip).not.toBe('203.0.113.50');
|
||||
});
|
||||
|
||||
it('honors forwarded client addresses from an allowlisted proxy peer', async () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
const app = createApp();
|
||||
app.get('/peer-ip', (req, res) => res.json({ ip: req.ip }));
|
||||
|
||||
const res = await request(app)
|
||||
.get('/peer-ip')
|
||||
.set('X-Forwarded-For', '203.0.113.50');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ip).toBe('203.0.113.50');
|
||||
});
|
||||
|
||||
it('ignores a forwarded HTTPS scheme from an untrusted direct peer', async () => {
|
||||
const app = createApp();
|
||||
app.get('/request-scheme', (req, res) => {
|
||||
res.json({ protocol: req.protocol, secure: isSecureRequest(req) });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/request-scheme')
|
||||
.set('X-Forwarded-Proto', 'https');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ protocol: 'http', secure: false });
|
||||
});
|
||||
|
||||
it('honors a forwarded HTTPS scheme from an allowlisted proxy peer', async () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '127.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
const app = createApp();
|
||||
app.get('/request-scheme', (req, res) => {
|
||||
res.json({ protocol: req.protocol, secure: isSecureRequest(req) });
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/request-scheme')
|
||||
.set('X-Forwarded-Proto', 'https');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ protocol: 'https', secure: true });
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,13 @@ describe('trustedProxyCidrs', () => {
|
||||
expect(isTrustedProxyPeer('192.168.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches IPv4-mapped IPv6 peers against IPv4 CIDRs', () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = '10.0.0.0/8';
|
||||
resetTrustedProxyBlockListCache();
|
||||
expect(isTrustedProxyPeer('::ffff:10.1.2.3')).toBe(true);
|
||||
expect(isTrustedProxyPeer('::ffff:192.168.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on invalid entries', () => {
|
||||
process.env.SENCHO_TRUSTED_PROXY_CIDRS = 'not-a-cidr';
|
||||
resetTrustedProxyBlockListCache();
|
||||
|
||||
Reference in New Issue
Block a user