Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/docs/src/content/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,11 @@ Every server that asks for the code (the Vite plugin, the Express hub and the CL

A refused code is never compared with the real one. devframe gives you a new code after 5 wrong ones, so a client that keeps guessing can change the code you are typing at most twice a minute. If the terminal says the server stopped accepting codes and you didn't type them, something on your machine or network is guessing the code. Restart the dev server to get a new code.

A code lasts 5 minutes. If you type the right code after it expires, the server refuses it without counting it as a wrong code, and devframe gives you a new one. Only the code that is current when the attempt arrives is left uncounted, so a client that guesses gets no free attempts.

#### Trusted browsers

A browser that exchanges the code gets a session token, so it doesn't need the code again after a reload. The server keeps those tokens in `~/.pangular-inspector/auth/<project>/auth.json`, one folder per project (named after a hash of its path), so a token from one project doesn't open another project's server. The folder is readable by you only (mode `0700`), and the file too (`0600`). A token expires 7 days after the code was exchanged. At startup the server drops entries in the file that aren't token records, and it only trusts a token that has its own entry, so a name such as `constructor` or `toString` is never a token. To sign out every browser, delete the project's folder, or all of `~/.pangular-inspector/auth`, and restart the server.
A browser that exchanges the code gets a session token, so it doesn't need the code again after a reload. The server keeps those tokens in `~/.pangular-inspector/auth/<project>/auth.json`, one folder per project (named after a hash of its path), so a token from one project doesn't open another project's server. The folder is readable by you only (mode `0700`), and the file too (`0600`). A token expires 7 days after the code was exchanged. At that moment the server drops it, and a page or panel still connected with it is no longer trusted. At startup the server drops entries in the file that aren't token records, and it only trusts a token that has its own entry, so a name such as `constructor` or `toString` is never a token. To sign out every browser, delete the project's folder, or all of `~/.pangular-inspector/auth`, and restart the server.

Version 0.0.7 and earlier kept the tokens in `~/.devframes/devframe/auth.json` (the Express hub and the Vite plugin) or `~/.pangular/devframe/auth.json` (the CLI), readable by other users and without expiry. Newer versions don't read those files. Delete them if no other devframe tool uses them.

Expand Down
2 changes: 2 additions & 0 deletions packages/devtools/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN
- Keep the Express hub and the overlay off in production unless `enabled: true` is passed to the hub.
- Back off and lock the server after wrong one-time codes, and never compare a refused code with the real one.
- Keep trusted session tokens in a `0600` file per project, and expire them after 7 days.
- Expire a session token at the moment it turns 7 days old, not at the next hourly check, and stop trusting the connections that still use it. devframe trusts any token left in the store and kept already trusted connections after their token was dropped.
- Don't count the right one-time code sent after it expired as a wrong guess. A slow developer no longer drifts toward the backoff and the lock. Wrong codes still count, also after the code expired.
- Stop `server.allowedHosts: true` from turning off the origin checks, and warn about it.
- Mask URL passwords and `Authorization` credentials (`Basic`, `Digest`, `Token`) in everything sent to the panel and to agents.
- Mask more secrets: OpenAI, Anthropic, GitLab, npm, Hugging Face and SendGrid keys, Slack webhook URLs, the `Ocp-Apim-Subscription-Key` header, secret values that run past separators, secret XML elements and attributes, and token-like positional parts of TanStack Query keys and mutation variables.
Expand Down
143 changes: 142 additions & 1 deletion packages/devtools/src/__tests__/auth-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import {
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { connectDevframe } from 'devframe/client';
import { getTempAuthCode } from 'devframe/node/auth';
import { getTempAuthCode, verifyAuthToken } from 'devframe/node/auth';
import { getInternalContext } from 'devframe/node/hub-internals';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CodeGuard, guardedExchange } from '../auth-guard.ts';
import {
authStoreDir,
expireAuthTokens,
hardenTokenStore,
pruneExpiredTokens,
AUTH_TOKEN_TTL_MS,
Expand Down Expand Up @@ -121,6 +123,53 @@ describe('guardedExchange', () => {
expect(onTrusted).toHaveBeenCalledOnce();
});

describe("with devframe's exchange", () => {
// devframe's one-time code lives 5 minutes.
const CODE_TTL_MS = 5 * 60_000;
let dir = '';
afterEach(() => {
vi.useRealTimers();
if (dir) rmSync(dir, { recursive: true, force: true });
});

function devframeContext(meta: object) {
dir = mkdtempSync(join(tmpdir(), 'pangular-code-'));
return { ...context(meta), host: { getStorageDir: () => dir } };
}

it('does not count a right code that expired before it arrived as a wrong guess', () => {
vi.useFakeTimers({ toFake: ['Date'] });
const meta = {};
const guard = new CodeGuard({ warn: vi.fn() });
const handler = guardedExchange(devframeContext(meta) as never, guard);
// A slow developer types the code just after it expires, more often than the lock allows.
for (let i = 0; i < 60; i++) {
const code = getTempAuthCode();
vi.setSystemTime(Date.now() + CODE_TTL_MS + 1);
expect(handler({ code, ua: '', origin: '' }).authToken).toBeNull();
}
expect(guard.locked).toBe(false);
expect(guard.check(meta)).toBe('ok');
expect(handler({ code: getTempAuthCode(), ua: '', origin: '' }).authToken).toBeTruthy();
});

it('still counts a wrong code, also one sent after the code expired', () => {
vi.useFakeTimers({ toFake: ['Date'] });
const meta = {};
const guard = new CodeGuard({ warn: vi.fn() });
const handler = guardedExchange(devframeContext(meta) as never, guard);
const wrong = (code: string) => (code === '000000' ? '000001' : '000000');
handler({ code: wrong(getTempAuthCode()), ua: '', origin: '' });
for (let i = 0; i < 2; i++) {
const code = getTempAuthCode();
vi.setSystemTime(Date.now() + CODE_TTL_MS + 1);
handler({ code: wrong(code), ua: '', origin: '' });
}
// Three wrong guesses start the wait.
expect(guard.check(meta)).toBe('backoff');
});
});

it('answers null for a call without a session or a code', () => {
const exchange = vi.fn(() => 'tok');
const ctx = { rpc: { ...context({}).rpc, getCurrentRpcSession: () => undefined } };
Expand Down Expand Up @@ -173,6 +222,78 @@ describe('pruneExpiredTokens', () => {
});
});

describe('expireAuthTokens', () => {
let dir = '';
afterEach(() => {
vi.useRealTimers();
if (dir) rmSync(dir, { recursive: true, force: true });
});

function fakeContext(meta: Record<string, unknown>) {
dir = mkdtempSync(join(tmpdir(), 'pangular-expiry-'));
return {
mode: 'dev',
host: { getStorageDir: () => dir },
// devframe's RPC host: the live connections, each with its session meta.
rpc: { _rpcGroup: { clients: [{ $meta: meta }] }, broadcast: vi.fn(async () => undefined) },
};
}

it('drops a token at its exact expiry and untrusts the connections that use it', () => {
vi.useFakeTimers();
const start = Date.UTC(2026, 0, 1);
vi.setSystemTime(start);
const meta: Record<string, unknown> = { id: 'a', isTrusted: true, clientAuthToken: 'tok' };
const ctx = fakeContext(meta);
const storage = getInternalContext(ctx as never).storage.auth;
const onPruned = vi.fn();
expireAuthTokens(ctx, onPruned);
// Issued half an hour after the server started, so off the hourly beat.
vi.advanceTimersByTime(30 * 60_000);
const issuedAt = Date.now();
storage.mutate((state) => {
state.trusted['tok'] = { authToken: 'tok', ua: '', origin: '', timestamp: issuedAt };
});

vi.advanceTimersByTime(AUTH_TOKEN_TTL_MS - 1);
expect(storage.value().trusted['tok']).toBeDefined();
expect(meta['isTrusted']).toBe(true);

vi.advanceTimersByTime(1);
expect(Date.now()).toBe(issuedAt + AUTH_TOKEN_TTL_MS);
expect(storage.value().trusted['tok']).toBeUndefined();
expect(verifyAuthToken('tok', { meta: {} } as never, storage)).toBe(false);
expect(meta['isTrusted']).toBe(false);
expect(meta['clientAuthToken']).toBeUndefined();
expect(ctx.rpc.broadcast).toHaveBeenCalledOnce();
expect(onPruned).toHaveBeenCalledOnce();
});

it('untrusts a live connection once the prune has dropped its token', () => {
vi.useFakeTimers();
vi.setSystemTime(Date.UTC(2026, 0, 1));
const meta: Record<string, unknown> = { id: 'a', isTrusted: true, clientAuthToken: 'tok' };
const other: Record<string, unknown> = { id: 'b', isTrusted: true, clientAuthToken: 'kept' };
const ctx = fakeContext(meta);
ctx.rpc._rpcGroup.clients.push({ $meta: other });
const storage = getInternalContext(ctx as never).storage.auth;
storage.mutate((state) => {
state.trusted['tok'] = { authToken: 'tok', ua: '', origin: '', timestamp: Date.now() };
});
expireAuthTokens(ctx);
vi.advanceTimersByTime(3 * 60 * 60_000);
storage.mutate((state) => {
state.trusted['kept'] = { authToken: 'kept', ua: '', origin: '', timestamp: Date.now() };
});
// Two hours past the first token's expiry, so any schedule has pruned it by now.
vi.advanceTimersByTime(AUTH_TOKEN_TTL_MS - 60 * 60_000);
expect(storage.value().trusted['kept']).toBeDefined();
expect(storage.value().trusted['tok']).toBeUndefined();
expect(meta['isTrusted']).toBe(false);
expect(other['isTrusted']).toBe(true);
});
});

describe('initPangularHub one-time code', () => {
const cleanups: (() => Promise<void> | void)[] = [];
afterEach(async () => {
Expand Down Expand Up @@ -258,6 +379,26 @@ describe('initPangularHub one-time code', () => {
expect(existsSync(join(home, '.devframes', 'devframe', 'auth.json'))).toBe(false);
});

it('stops trusting a connected client when its token expires', async () => {
// The token has four seconds left, room for the hub to start and the client to be trusted.
const issuedAt = Date.now() - AUTH_TOKEN_TTL_MS + 4000;
const { connect } = await serve((home) => {
const dir = authStoreDir(process.cwd(), home);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'auth.json'),
JSON.stringify({
trusted: { 'expiring-token': { authToken: 'expiring-token', timestamp: issuedAt } },
}),
);
});
const client = await connect('expiring-token');
expect(await client.ensureTrusted(1000)).toBe(true);
await vi.waitFor(() => expect(client.isTrusted).toBe(false), { timeout: 6000 });
const again = await connect('expiring-token');
expect(await again.ensureTrusted(500).catch(() => false)).toBe(false);
}, 15_000);

it('drops expired tokens at startup and keeps the rewritten file private', async () => {
const old = Date.now() - AUTH_TOKEN_TTL_MS - 1000;
let file = '';
Expand Down
43 changes: 36 additions & 7 deletions packages/devtools/src/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
//
// Attempts the guard refuses are not checked, so they don't rotate the code
// the developer is typing either.
import { exchangeTempAuthCode } from 'devframe/node/auth';
//
// devframe answers `null` both for a wrong code and for the right code sent
// after it expired. Only a wrong code counts: an attempt is left uncounted
// when it carries exactly the code that was current when it arrived, which
// only the developer (or a right guess) can send, so no guess goes uncounted.
import { createHash, timingSafeEqual } from 'node:crypto';
import { exchangeTempAuthCode, getTempAuthCode } from 'devframe/node/auth';
import { getInternalContext } from 'devframe/node/hub-internals';

const EXCHANGE = 'anonymous:devframe:auth:exchange';
Expand Down Expand Up @@ -142,13 +148,34 @@ interface GuardContext {
};
}

/** devframe's exchange: checks the code, and on a match stores and returns a new token. */
export type CodeExchange = (params: ExchangeParams, session: AuthSession) => string | null;
/**
* devframe's exchange: checks the code, and on a match stores and returns a
* new token. `'expired'` means the code was the current one but had expired,
* so devframe rotated it; `null` means a wrong code.
*/
export type CodeExchange = (
params: ExchangeParams,
session: AuthSession,
) => string | null | 'expired';

const digest = (value: string) => createHash('sha256').update(value).digest();

/** Compares two codes in constant time, whatever their lengths. */
function sameCode(a: string, b: string): boolean {
return timingSafeEqual(digest(a), digest(b));
}

function defaultExchange(ctx: GuardContext): CodeExchange {
return (params, session) => {
const storage = getInternalContext(ctx as never).storage.auth;
return exchangeTempAuthCode(params.code, session as never, params, storage);
// The code devframe checks against. Reading it changes nothing: devframe
// only creates one when there is none, as the exchange would.
const current = getTempAuthCode();
const authToken = exchangeTempAuthCode(params.code, session as never, params, storage);
if (authToken) return authToken;
// devframe accepts the current code unless it has expired, so a refused
// current code was an expired one.
return sameCode(params.code, current) ? 'expired' : null;
};
}

Expand All @@ -165,15 +192,17 @@ export function guardedExchange(
if (typeof params?.code !== 'string' || guard.check(session.meta) !== 'ok') {
return { authToken: null };
}
const authToken = exchange(
const result = exchange(
{ code: params.code, ua: String(params.ua ?? ''), origin: String(params.origin ?? '') },
session,
);
if (authToken) {
// The right code, too late: not a guess, so neither counted nor trusted.
if (result === 'expired') return { authToken: null };
if (result) {
guard.succeeded(session.meta);
onTrusted?.();
} else guard.failed(session.meta);
return { authToken };
return { authToken: result };
};
}

Expand Down
Loading
Loading