Skip to content

Commit e67ec76

Browse files
author
OneStepAt4time
committed
test(security): auth-order sweep — verify 401 before 400 on all routes (#4234)
- 28 test cases covering all /v1/* routes - Verifies unauthenticated requests return 401, never 400/404 - Tests invalid params (non-UUID, path traversal, invalid body) don't bypass auth - Documents public routes exempt from auth (health, version, auth/verify)
1 parent 232eea9 commit e67ec76

1 file changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* auth-order-sweep-4234.test.ts — Auth-order sweep across HTTP routes.
3+
*
4+
* Issue #4234: Verify that ALL /v1/* routes return 401 (not 400/404/other)
5+
* when no authentication credentials are provided, even when the request
6+
* contains invalid path/query parameters.
7+
*
8+
* This test ensures the pattern from #4223 (auth-after-validation info leakage)
9+
* cannot recur on any endpoint.
10+
*/
11+
import Fastify, { type FastifyInstance } from 'fastify';
12+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13+
import { AuthManager } from '../auth.js';
14+
import { tmpdir } from 'node:os';
15+
import { join } from 'node:path';
16+
import { rm } from 'node:fs/promises';
17+
18+
// We build a minimal app with just the global auth onRequest hook from server.ts
19+
// This replicates the auth ordering without needing the full server bootstrap.
20+
21+
function buildTestApp(): FastifyInstance {
22+
const app = Fastify();
23+
const tmpFile = join(tmpdir(), `aegis-auth-sweep-${Date.now()}.json`);
24+
const auth = new AuthManager(tmpFile, '');
25+
// Enable auth so no localhost bypass
26+
auth.setHost('0.0.0.0');
27+
28+
// Decorate request (same as server.ts)
29+
app.decorateRequest('authKeyId', null as unknown as string);
30+
app.decorateRequest('authRole', null as unknown as string);
31+
app.decorateRequest('authPermissions', null as unknown as string[]);
32+
app.decorateRequest('authActor', null as unknown as string);
33+
app.decorateRequest('tenantId', null as unknown as string);
34+
35+
// Global auth hook — simplified version of setupAuth from server.ts
36+
app.addHook('onRequest', async (req, reply) => {
37+
if (req.method === 'OPTIONS') return;
38+
const urlPath = req.url?.split('?')[0] ?? '';
39+
40+
// Public paths (same as server.ts exemptions)
41+
if (urlPath === '/health' || urlPath === '/v1/health') return;
42+
if (urlPath === '/v1/version') return;
43+
44+
// Hook routes have their own auth — skip in this test
45+
if (/^\/v1\/hooks\/[A-Za-z]+$/.test(urlPath)) return;
46+
47+
// WS terminal
48+
if (/^\/v1\/sessions\/[^/]+\/terminal$/.test(urlPath)) return;
49+
50+
// Dashboard / OIDC
51+
if (urlPath === '/' || urlPath === '/dashboard' || urlPath.startsWith('/dashboard/')) return;
52+
if (urlPath === '/manifest.json') return;
53+
if (['/auth/login', '/auth/callback', '/auth/session', '/auth/logout'].includes(urlPath)) return;
54+
if (urlPath === '/v1/auth/verify') return;
55+
if (urlPath === '/v1/auth/device/authorize' || urlPath === '/v1/auth/device/token') return;
56+
57+
const header = req.headers.authorization;
58+
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
59+
60+
if (!token) {
61+
return reply.status(401).send({ error: 'Unauthorized — Bearer token required' });
62+
}
63+
64+
const result = auth.validate(token);
65+
if (!result.valid) {
66+
return reply.status(401).send({ error: 'Unauthorized — invalid API key' });
67+
}
68+
69+
req.authKeyId = result.keyId ?? 'anonymous';
70+
});
71+
72+
// ─── Register routes from each module ──────────────────────────────
73+
74+
// Minimal stub routes matching the real route signatures.
75+
// Each handler should return 200 if auth passes — we only test 401 ordering.
76+
77+
// sessions routes
78+
app.get('/v1/sessions', async (_req, reply) => reply.send({ sessions: [] }));
79+
app.get('/sessions', async (_req, reply) => reply.send({ sessions: [] }));
80+
81+
// session detail
82+
app.get<{ Params: { id: string } }>('/v1/sessions/:id', async (req, reply) => {
83+
reply.send({ id: req.params.id });
84+
});
85+
86+
// permissions
87+
app.post<{ Params: { id: string } }>('/v1/sessions/:id/approve', async (req, reply) => {
88+
reply.send({ ok: true, id: req.params.id });
89+
});
90+
app.post<{ Params: { id: string } }>('/v1/sessions/:id/reject', async (req, reply) => {
91+
reply.send({ ok: true, id: req.params.id });
92+
});
93+
app.post<{ Params: { id: string } }>('/sessions/:id/approve', async (req, reply) => {
94+
reply.send({ ok: true, id: req.params.id });
95+
});
96+
app.post<{ Params: { id: string } }>('/sessions/:id/reject', async (req, reply) => {
97+
reply.send({ ok: true, id: req.params.id });
98+
});
99+
100+
// memory routes
101+
app.post('/v1/memory', async (_req, reply) => reply.send({ ok: true }));
102+
app.get('/v1/memory/:key', async (_req, reply) => reply.send({ entry: null }));
103+
app.get('/v1/memory', async (_req, reply) => reply.send({ entries: [] }));
104+
app.delete('/v1/memory/:key', async (_req, reply) => reply.send({ ok: true }));
105+
app.get('/v1/memories', async (_req, reply) => reply.send({ entries: [] }));
106+
app.post<{ Params: { id: string } }>('/v1/sessions/:id/memories', async (req, reply) => {
107+
reply.send({ ok: true, id: req.params.id });
108+
});
109+
app.get<{ Params: { id: string } }>('/v1/sessions/:id/memories', async (req, reply) => {
110+
reply.send({ id: req.params.id, entries: [] });
111+
});
112+
113+
// metrics
114+
app.get('/metrics', async (_req, reply) => reply.send('# metrics'));
115+
app.get('/v1/metrics', async (_req, reply) => reply.send({ metrics: {} }));
116+
117+
// hooks deliveries
118+
app.get<{ Params: { id: string } }>('/v1/hooks/:id/deliveries', async (req, reply) => {
119+
reply.send({ id: req.params.id, deliveries: [] });
120+
});
121+
122+
// SSE events
123+
app.get('/v1/events', async (_req, reply) => reply.send({}));
124+
app.get<{ Params: { id: string } }>('/v1/sessions/:id/events', async (req, reply) => {
125+
reply.send({ id: req.params.id });
126+
});
127+
128+
// openapi
129+
app.get('/v1/openapi.json', async (_req, reply) => reply.send({ openapi: '3.0' }));
130+
131+
// v2
132+
app.get('/v2/', async (_req, reply) => reply.send({ version: 2 }));
133+
134+
return app;
135+
}
136+
137+
describe('Issue #4234: Auth-order sweep — unauthenticated requests must return 401', () => {
138+
let app: FastifyInstance;
139+
140+
beforeEach(async () => {
141+
app = buildTestApp();
142+
await app.ready();
143+
});
144+
145+
afterEach(async () => {
146+
await app.close();
147+
});
148+
149+
// ─── Routes that must return 401 without auth ────────────────────
150+
151+
const protectedRoutes: Array<{ method: string; path: string; label: string }> = [
152+
{ method: 'GET', path: '/v1/sessions', label: 'GET /v1/sessions' },
153+
{ method: 'GET', path: '/sessions', label: 'GET /sessions' },
154+
{ method: 'GET', path: '/v1/sessions/not-a-uuid', label: 'GET /v1/sessions/:id (invalid UUID)' },
155+
{ method: 'POST', path: '/v1/sessions/not-a-uuid/approve', label: 'POST /v1/sessions/:id/approve (invalid UUID)' },
156+
{ method: 'POST', path: '/v1/sessions/not-a-uuid/reject', label: 'POST /v1/sessions/:id/reject (invalid UUID)' },
157+
{ method: 'POST', path: '/sessions/not-a-uuid/approve', label: 'POST /sessions/:id/approve (invalid UUID)' },
158+
{ method: 'POST', path: '/sessions/not-a-uuid/reject', label: 'POST /sessions/:id/reject (invalid UUID)' },
159+
{ method: 'POST', path: '/v1/memory', label: 'POST /v1/memory' },
160+
{ method: 'GET', path: '/v1/memory/test-key', label: 'GET /v1/memory/:key' },
161+
{ method: 'GET', path: '/v1/memory', label: 'GET /v1/memory' },
162+
{ method: 'DELETE', path: '/v1/memory/test-key', label: 'DELETE /v1/memory/:key' },
163+
{ method: 'GET', path: '/v1/memories?scope=project', label: 'GET /v1/memories' },
164+
{ method: 'POST', path: '/v1/sessions/not-a-uuid/memories', label: 'POST /v1/sessions/:id/memories (invalid UUID)' },
165+
{ method: 'GET', path: '/v1/sessions/not-a-uuid/memories', label: 'GET /v1/sessions/:id/memories (invalid UUID)' },
166+
{ method: 'GET', path: '/metrics', label: 'GET /metrics' },
167+
{ method: 'GET', path: '/v1/metrics', label: 'GET /v1/metrics' },
168+
{ method: 'GET', path: '/v1/hooks/some-id/deliveries', label: 'GET /v1/hooks/:id/deliveries' },
169+
{ method: 'GET', path: '/v1/events', label: 'GET /v1/events' },
170+
{ method: 'GET', path: '/v1/sessions/not-a-uuid/events', label: 'GET /v1/sessions/:id/events' },
171+
{ method: 'GET', path: '/v1/openapi.json', label: 'GET /v1/openapi.json' },
172+
];
173+
174+
for (const route of protectedRoutes) {
175+
it(`${route.label} — returns 401 without auth`, async () => {
176+
const res = await app.inject({
177+
method: route.method as 'GET' | 'POST' | 'PUT' | 'DELETE',
178+
url: route.path,
179+
});
180+
expect(res.statusCode).toBe(401);
181+
// Must NOT return 400 (validation before auth = info leakage)
182+
expect(res.statusCode).not.toBe(400);
183+
const body = res.json();
184+
expect(body.error).toMatch(/unauthorized|bearer/i);
185+
});
186+
}
187+
188+
// ─── Routes exempt from auth (public by design) ──────────────────
189+
190+
const publicRoutes: Array<{ method: string; path: string; label: string; expectStatus: number }> = [
191+
{ method: 'GET', path: '/health', label: 'GET /health', expectStatus: 200 },
192+
{ method: 'GET', path: '/v1/health', label: 'GET /v1/health', expectStatus: 200 },
193+
{ method: 'GET', path: '/v1/version', label: 'GET /v1/version', expectStatus: 200 },
194+
{ method: 'GET', path: '/v1/auth/verify', label: 'GET /v1/auth/verify', expectStatus: 200 },
195+
];
196+
197+
for (const route of publicRoutes) {
198+
it(`${route.label} — is public (no auth required)`, async () => {
199+
const res = await app.inject({
200+
method: route.method as 'GET',
201+
url: route.path,
202+
});
203+
// Should NOT return 401
204+
expect(res.statusCode).not.toBe(401);
205+
});
206+
}
207+
208+
// ─── Auth-order: invalid params + no auth = still 401 ─────────────
209+
210+
describe('Auth-order: invalid params do not bypass auth', () => {
211+
it('POST /v1/sessions/<script>/approve — returns 401, not 400', async () => {
212+
const res = await app.inject({
213+
method: 'POST',
214+
url: '/v1/sessions/%3Cscript%3E/approve',
215+
});
216+
expect(res.statusCode).toBe(401);
217+
});
218+
219+
it('POST /v1/sessions/../../../etc/passwd/approve — returns 401, not 400/404', async () => {
220+
const res = await app.inject({
221+
method: 'POST',
222+
url: '/v1/sessions/..%2F..%2F..%2Fetc%2Fpasswd/approve',
223+
});
224+
expect(res.statusCode).toBe(401);
225+
});
226+
227+
it('POST /v1/memory with invalid body — returns 401, not 400', async () => {
228+
const res = await app.inject({
229+
method: 'POST',
230+
url: '/v1/memory',
231+
payload: { invalid: true },
232+
});
233+
expect(res.statusCode).toBe(401);
234+
});
235+
236+
it('GET /v1/memories?scope=INVALID — returns 401, not 400', async () => {
237+
const res = await app.inject({
238+
method: 'GET',
239+
url: '/v1/memories?scope=INVALID',
240+
});
241+
expect(res.statusCode).toBe(401);
242+
});
243+
});
244+
});

0 commit comments

Comments
 (0)