mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 15:39:04 +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
@@ -60,14 +60,20 @@ export async function writeAudit(row: AuditRow, path: string = defaultAuditPath(
|
||||
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Non-reversible identifier for tokens/identities in logs and API responses.
|
||||
// Same device salt as the attempts log, so ids correlate across both.
|
||||
export async function saltedHash(raw: string): Promise<string> {
|
||||
const salt = await loadDeviceSalt();
|
||||
return createHash('sha256').update(salt + ':' + raw).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
export async function writeAttempt(opts: {
|
||||
rawIdentity: string;
|
||||
endpoint: string;
|
||||
reason: AttemptRow['reason'];
|
||||
path?: string;
|
||||
}): Promise<void> {
|
||||
const salt = await loadDeviceSalt();
|
||||
const hash = createHash('sha256').update(salt + ':' + opts.rawIdentity).digest('hex').slice(0, 16);
|
||||
const hash = await saltedHash(opts.rawIdentity);
|
||||
const row: AttemptRow = {
|
||||
ts: new Date().toISOString(),
|
||||
identity_canon: hash,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { probeTailscale, whoIs } from './tailscale-localapi';
|
||||
import { SessionTokenStore } from './session-tokens';
|
||||
import { mintForCaller } from './auth-mint';
|
||||
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer, saltedHash } from './audit';
|
||||
import { bootstrapTunnel } from './tunnel-bootstrap';
|
||||
import { startTunnelKeepalive } from './devicectl';
|
||||
import type { Capability } from './types';
|
||||
@@ -362,20 +362,38 @@ async function handleLoopback(ctx: HandlerCtx): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// /auth/sessions — list active sessions (owner only).
|
||||
// /auth/sessions — list active sessions (owner only). Raw token values
|
||||
// never leave the store: any local process can hit this listener, so a
|
||||
// list that echoed live bearer tokens was a harvest-and-replay primitive.
|
||||
// Callers get a salted-hash id plus metadata; revoke by identity, by the
|
||||
// token they already hold from mint, or by token_id from this list.
|
||||
if (method === 'GET' && path === '/auth/sessions') {
|
||||
sendJson(res, 200, { sessions: tokenStore.list() });
|
||||
const sessions = await Promise.all(tokenStore.list().map(async ({ token, ...meta }) => ({
|
||||
token_id: await saltedHash(token),
|
||||
...meta,
|
||||
})));
|
||||
sendJson(res, 200, { sessions });
|
||||
return;
|
||||
}
|
||||
|
||||
// /auth/revoke — revoke a token.
|
||||
// /auth/revoke — revoke by raw token (the caller's own, from mint), by
|
||||
// token_id (from /auth/sessions — keeps the list→revoke workflow alive
|
||||
// now that the list is hash-only), or by identity.
|
||||
if (method === 'POST' && path === '/auth/revoke') {
|
||||
const body = await readBody(req);
|
||||
if ('error' in body) { sendJson(res, 413, body); return; }
|
||||
const parsed = JSON.parse(body.toString('utf-8') || '{}') as { token?: string; identity?: string };
|
||||
const parsed = JSON.parse(body.toString('utf-8') || '{}') as {
|
||||
token?: string; token_id?: string; identity?: string;
|
||||
};
|
||||
let count = 0;
|
||||
if (parsed.token) {
|
||||
count = tokenStore.revoke(parsed.token) ? 1 : 0;
|
||||
} else if (parsed.token_id) {
|
||||
for (const s of tokenStore.list()) {
|
||||
if ((await saltedHash(s.token)) === parsed.token_id) {
|
||||
count += tokenStore.revoke(s.token) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
} else if (parsed.identity) {
|
||||
count = tokenStore.revokeByIdentity(parsed.identity);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user