mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
fix(ios-qa): /auth/sessions no longer hands raw bearer tokens to any local process
The loopback sessions list echoed live tokens — a harvest-and-replay primitive for anything on the machine (same class as the /health token leak fixed in v1.63). The list now returns a device-salted 16-hex token_id plus metadata; the salt is shared with the attempts log so identifiers correlate. /auth/revoke keeps the list→revoke workflow alive by accepting token_id alongside the caller's own raw token and identity. saltedHash() is exported from audit.ts and writeAttempt now reuses it (was inlined). Integration tests pin raw-token absence, the id shape/metadata, and the token_id revoke round-trip (verified RED against the leaking handler). List fix ported from time-attack/gstack (GStack 2); token_id revoke is ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Sina Matian
Claude Fable 5
parent
d2257abedd
commit
f31aff1bc6
@@ -107,6 +107,57 @@ describe('daemon — loopback listener', () => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('/auth/sessions returns salted-hash ids and metadata, never raw tokens', async () => {
|
||||
const minted = daemon.tokenStore.mint({
|
||||
identity: 'owner@example.com',
|
||||
capability: 'interact',
|
||||
deviceUdid: 'STUB-UDID',
|
||||
origin: 'owner_granted',
|
||||
});
|
||||
if ('error' in minted) throw new Error(minted.error);
|
||||
try {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
|
||||
expect(r.status).toBe(200);
|
||||
// The harvest-and-replay primitive: any local process could read live
|
||||
// bearer tokens off this endpoint. The raw token must never appear.
|
||||
expect(r.bodyText).not.toContain(minted.token);
|
||||
|
||||
const { sessions } = JSON.parse(r.bodyText) as { sessions: Array<Record<string, unknown>> };
|
||||
const row = sessions.find(s => s.identity === 'owner@example.com');
|
||||
expect(row).toMatchObject({
|
||||
capability: 'interact',
|
||||
device_udid: 'STUB-UDID',
|
||||
origin: 'owner_granted',
|
||||
expires_at: minted.expires_at,
|
||||
});
|
||||
expect(row?.token_id).toMatch(/^[0-9a-f]{16}$/);
|
||||
expect(row?.token).toBeUndefined();
|
||||
} finally {
|
||||
daemon.tokenStore.revoke(minted.token);
|
||||
}
|
||||
});
|
||||
|
||||
test('revoke by token_id from the hash-only list still works (list→revoke)', async () => {
|
||||
const minted = daemon.tokenStore.mint({
|
||||
identity: 'revoke-by-id@example.com',
|
||||
capability: 'observe',
|
||||
origin: 'owner_granted',
|
||||
});
|
||||
if ('error' in minted) throw new Error(minted.error);
|
||||
|
||||
const list = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/auth/sessions`);
|
||||
const { sessions } = JSON.parse(list.bodyText) as { sessions: Array<Record<string, unknown>> };
|
||||
const row = sessions.find(s => s.identity === 'revoke-by-id@example.com');
|
||||
expect(row?.token_id).toBeDefined();
|
||||
|
||||
const revoke = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/auth/revoke`, {
|
||||
body: JSON.stringify({ token_id: row!.token_id }),
|
||||
});
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(JSON.parse(revoke.bodyText).revoked).toBe(1);
|
||||
expect(daemon.tokenStore.list().some(s => s.identity === 'revoke-by-id@example.com')).toBe(false);
|
||||
});
|
||||
|
||||
test('healthz returns 200 with mode=loopback', async () => {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user