mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 23:49:01 +02:00
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>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
// Audit + attempts logging. Reuses the same rotation primitives as
|
|
// browse/src/tunnel-denial-log.ts (10MB rotation, 5 generations).
|
|
|
|
import { mkdir, appendFile, stat, rename, readFile } from 'fs/promises';
|
|
import { join, dirname } from 'path';
|
|
import { homedir } from 'os';
|
|
import { createHash } from 'crypto';
|
|
import type { AuditRow, AttemptRow } from './types';
|
|
|
|
const MAX_BYTES = 10 * 1024 * 1024;
|
|
const MAX_GENS = 5;
|
|
|
|
export function defaultAuditPath(): string {
|
|
return process.env.GSTACK_IOS_AUDIT_PATH
|
|
?? join(homedir(), '.gstack', 'security', 'ios-qa-audit.jsonl');
|
|
}
|
|
|
|
export function defaultAttemptsPath(): string {
|
|
return process.env.GSTACK_IOS_ATTEMPTS_PATH
|
|
?? join(homedir(), '.gstack', 'security', 'attempts.jsonl');
|
|
}
|
|
|
|
let _saltCache: string | null = null;
|
|
|
|
async function loadDeviceSalt(): Promise<string> {
|
|
if (_saltCache) return _saltCache;
|
|
const path = join(homedir(), '.gstack', 'security', 'device-salt');
|
|
try {
|
|
_saltCache = (await readFile(path, 'utf-8')).trim();
|
|
} catch {
|
|
// No salt; generate ephemeral. Real install writes one via /setup.
|
|
const { randomBytes } = await import('crypto');
|
|
_saltCache = randomBytes(32).toString('hex');
|
|
}
|
|
return _saltCache!;
|
|
}
|
|
|
|
async function rotateIfNeeded(path: string): Promise<void> {
|
|
try {
|
|
const s = await stat(path);
|
|
if (s.size < MAX_BYTES) return;
|
|
} catch {
|
|
return; // file doesn't exist yet
|
|
}
|
|
// Rotate: path → path.1 → path.2 → ... → path.MAX_GENS
|
|
for (let i = MAX_GENS - 1; i >= 0; i--) {
|
|
const src = i === 0 ? path : `${path}.${i}`;
|
|
const dst = `${path}.${i + 1}`;
|
|
try {
|
|
await rename(src, dst);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function writeAudit(row: AuditRow, path: string = defaultAuditPath()): Promise<void> {
|
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
await rotateIfNeeded(path);
|
|
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 hash = await saltedHash(opts.rawIdentity);
|
|
const row: AttemptRow = {
|
|
ts: new Date().toISOString(),
|
|
identity_canon: hash,
|
|
endpoint: opts.endpoint,
|
|
reason: opts.reason,
|
|
};
|
|
const path = opts.path ?? defaultAttemptsPath();
|
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
await rotateIfNeeded(path);
|
|
await appendFile(path, JSON.stringify(row) + '\n', { mode: 0o600 });
|
|
}
|
|
|
|
// Sanitize-replacer for JSON responses — mirrors browse's sanitize-replacer.ts.
|
|
// Strips lone UTF-16 surrogate halves that would otherwise reach the
|
|
// Anthropic API as \uD800-style escapes and trigger 400.
|
|
export function sanitizeReplacer(_key: string, value: unknown): unknown {
|
|
if (typeof value !== 'string') return value;
|
|
// Replace lone high surrogates not followed by low surrogates, and lone
|
|
// low surrogates not preceded by high surrogates.
|
|
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�');
|
|
}
|