Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2b2d4a7
Harden WebSocket message handling and stop logging secrets
Tim020 Oct 19, 2026
8d9e2fc
Require auth and bound client log posts; stop disclosing server paths…
Tim020 Oct 19, 2026
357b376
Security hygiene: JSON body helper, 403 for non-admin, RBAC validatio…
Tim020 Oct 19, 2026
207c03a
Move /debug access test next to the existing debug endpoint tests
Tim020 Oct 19, 2026
10c39b1
Make log redaction setting authoritative and default it on
Tim020 Oct 19, 2026
cad9f11
Redistribute tests into modules mirroring the code under test
Tim020 Oct 19, 2026
603542e
Reduce cognitive complexity of REST body logging and WS session op di…
Tim020 Oct 20, 2026
78d830e
Merge remote-tracking branch 'origin/feature/security-websocket' into…
Tim020 Oct 20, 2026
5637276
Merge remote-tracking branch 'origin/dev' into feature/security-hygiene
Tim020 Oct 20, 2026
1c91d98
Merge dev; non-admin show delete now returns 403
Tim020 Oct 20, 2026
abd282e
Address review: fix redaction crash on absent decorated path, log mal…
Tim020 Oct 20, 2026
1298148
Merge remote-tracking branch 'origin/feature/security-websocket' into…
Tim020 Oct 20, 2026
1446211
Merge feature/security-hygiene into feature/security-logs-and-disclosure
Tim020 Oct 20, 2026
3a63505
Mark sensitive settings with a define() kwarg instead of a key allow-…
Tim020 Oct 20, 2026
954a0be
Shrink the client logger change to cut new-code duplication
Tim020 Oct 20, 2026
a578c92
Merge dev into feature/security-logs-and-disclosure
Tim020 Oct 20, 2026
a7db686
Address review: filter file-reload broadcast, harden log parsing, ref…
Tim020 Oct 21, 2026
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
62 changes: 62 additions & 0 deletions client-v3/src/js/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const userState: { authToken: string | null } = { authToken: null };

vi.mock('@/stores/user', () => ({ useUserStore: () => userState }));
vi.mock('@/stores/system', () => ({
useSystemStore: () => ({ settings: { client_log_enabled: true, client_log_level: 'DEBUG' } }),
}));

describe('remote logger', () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response('{}')));

beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
fetchMock.mockClear();
vi.stubGlobal('fetch', fetchMock);
userState.authToken = null;
const { initRemoteLogging } = await import('./logger');
initRemoteLogging();
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('holds log entries until a token exists, then sends them with it', async () => {
const log = (await import('loglevel')).default;
log.error('before login');
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).not.toHaveBeenCalled();

userState.authToken = 'tok';
log.error('after login');
await vi.advanceTimersByTimeAsync(2000);

expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
const messages = JSON.parse(init.body as string).batch.map(
(e: { message: string }) => e.message
);
expect(messages).toEqual(expect.arrayContaining(['before login', 'after login']));
});

it('keeps only the most recent 50 entries while logged out', async () => {
const log = (await import('loglevel')).default;
for (let i = 0; i < 80; i++) log.error(`entry ${i}`);

userState.authToken = 'tok';
await vi.advanceTimersByTimeAsync(2000);
log.error('final');
await vi.advanceTimersByTimeAsync(2000);

const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const batch = JSON.parse(init.body as string).batch as { message: string }[];
expect(batch.length).toBeLessThanOrEqual(50);
expect(batch.map((e) => e.message)).not.toContain('entry 0');
expect(batch.map((e) => e.message)).toContain('final');
});
});
7 changes: 6 additions & 1 deletion client-v3/src/js/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;

function enqueueLog(level: string, message: string, extra: Record<string, unknown>): void {
logQueue.push({ level, message, extra });
if (logQueue.length > MAX_QUEUE_SIZE) logQueue.shift();
if (logQueue.length >= MAX_QUEUE_SIZE) {
flushQueue();
return;
Expand All @@ -33,9 +34,13 @@ function flushQueue() {
flushTimer = null;
if (logQueue.length === 0) return;

// The server only accepts log posts from logged-in users, so entries stay queued
// (capped in enqueueLog) until a token exists.
const token = useUserStore().authToken;
if (!token) return;

const batch = logQueue.splice(0);

const token = useUserStore().authToken;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;

Expand Down
2 changes: 2 additions & 0 deletions client-v3/src/stores/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const useUserStore = defineStore('user', {

try {
await useSystemStore().getRbacRoles();
await useSystemStore().getRawSettings();
await this.getCurrentUser();
await this.getCurrentRbac();
await this.getUserSettings();
Expand Down Expand Up @@ -94,6 +95,7 @@ export const useUserStore = defineStore('user', {
this.userSettings = {};
this.stageDirectionStyleOverrides = [];
this.cueColourOverrides = [];
void useSystemStore().getRawSettings(); // drop admin-only values from the cache

// Mark the open WebSocket as waiting for authentication (as the legacy
// client's CLEAR_WS_AUTHENTICATION does), so the next in-app login
Expand Down
65 changes: 65 additions & 0 deletions client/src/js/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const getters: Record<string, unknown> = {};

vi.mock('@/store/store', () => ({ default: { getters, watch: vi.fn() } }));

describe('remote logger', () => {
const fetchMock = vi.fn(() => Promise.resolve(new Response('{}')));
let log: typeof import('loglevel').default;

const sentBatches = () =>
fetchMock.mock.calls.map((call) => {
const [, init] = call as unknown as [string, RequestInit];
return {
auth: (init.headers as Record<string, string>).Authorization,
messages: (JSON.parse(init.body as string).batch as { message: string }[]).map(
(e) => e.message
),
};
});

beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
fetchMock.mockClear();
vi.stubGlobal('fetch', fetchMock);
getters.AUTH_TOKEN = null;
getters.SETTINGS = { client_log_enabled: true, client_log_level: 'DEBUG' };
await (await import('./logger')).initRemoteLogging();
log = (await import('loglevel')).default;
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it('does not post while logged out and flushes the held entries after login', async () => {
log.error('queued while logged out');
await vi.advanceTimersByTimeAsync(2000);
expect(fetchMock).not.toHaveBeenCalled();

getters.AUTH_TOKEN = 'tok';
log.error('logged in');
await vi.advanceTimersByTimeAsync(2000);

const [batch] = sentBatches();
expect(batch.auth).toBe('Bearer tok');
expect(batch.messages).toEqual(
expect.arrayContaining(['queued while logged out', 'logged in'])
);
});

it('caps the logged-out queue at the 50 most recent entries', async () => {
for (let i = 0; i < 80; i++) log.error(`entry ${i}`);
getters.AUTH_TOKEN = 'tok';
log.error('final');
await vi.advanceTimersByTimeAsync(2000);

const [batch] = sentBatches();
expect(batch.messages.length).toBeLessThanOrEqual(50);
expect(batch.messages).not.toContain('entry 0');
expect(batch.messages).toContain('final');
});
});
7 changes: 6 additions & 1 deletion client/src/js/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
*/
function enqueueLog(level: string, message: string, extra: Record<string, unknown>): void {
logQueue.push({ level, message, extra });
if (logQueue.length > MAX_QUEUE_SIZE) logQueue.shift();
if (logQueue.length >= MAX_QUEUE_SIZE) {
flushQueue();
return;
Expand All @@ -39,9 +40,13 @@ function flushQueue() {
flushTimer = null;
if (logQueue.length === 0) return;

// The server only accepts log posts from logged-in users, so entries stay queued
// (capped in enqueueLog) until a token exists.
const token = store.getters.AUTH_TOKEN;
if (!token) return;

const batch = logQueue.splice(0);

const token = store.getters.AUTH_TOKEN;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;

Expand Down
3 changes: 3 additions & 0 deletions client/src/store/modules/user/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const module: Module<UserState, RootState> = {
await context.commit('SET_AUTH_TOKEN', data.access_token);
}
await context.dispatch('GET_RBAC_ROLES');
await context.dispatch('GET_RAW_SETTINGS');
await context.dispatch('GET_CURRENT_USER');
await context.dispatch('GET_CURRENT_RBAC');
await context.dispatch('GET_USER_SETTINGS');
Expand Down Expand Up @@ -150,6 +151,8 @@ const module: Module<UserState, RootState> = {
await context.commit('SET_USER_SETTINGS', []);
await context.commit('SET_STAGE_DIRECTION_STYLE_OVERRIDES', []);
await context.commit('CLEAR_WS_AUTHENTICATION');
// Re-fetch (now anonymous) so admin-only values are dropped from the cache.
context.dispatch('GET_RAW_SETTINGS');
if (token) {
try {
const response = await fetch(makeURL('/api/v1/auth/logout'), {
Expand Down
2 changes: 2 additions & 0 deletions docs/pages/user_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ All log entries are attributed to the logged-in user where one is present:

- **Server logs**: Each HTTP access log line and request-body debug line includes `[username]` in the message (e.g. `200 GET /api/v1/user/settings (127.0.0.1) [alice] 5.23ms`). WebSocket messages and close events also include the username once the connection has been authenticated.
- **Client logs**: The server extracts the username from the JWT token on each batch submission, so all client log entries are attributed even if the client sends no user information itself.
- **Client log limits**: Submitting client logs requires being logged in, so entries generated before login are held in the browser and sent after login. A single batch may contain at most 100 entries, messages are truncated at 4 KB, oversized extra data is dropped, and each IP address is limited to 600 entries per minute (further entries are rejected until the window resets).
- **Server file paths**: The database, log, compiled script and draft script locations are only shown to administrators (on the Settings tab); other users and logged-out visitors never receive them.

#### Filtering

Expand Down
Loading
Loading