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:
Anso
2026-09-01 20:52:12 +00:00
committed by GitHub
parent 82dca29314
commit 79b86ddcd4
76 changed files with 1457 additions and 212 deletions
@@ -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();
}
});
});
+7 -7
View File
@@ -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');
+70 -7
View File
@@ -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);
});
+1 -1
View File
@@ -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();
});
});
+19 -1
View File
@@ -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();