security(relay): fix lockout DoS and DOM XSS in web client

Addresses two Major findings from PR review:

- Auth lockout no longer griefable: the throttle key is namespaced per path
  and includes the client IP (web:<session>:<ip> vs desktop:<session>:<ip>),
  so a flood of bad web-client auths can neither lock other clients of the
  same session nor block the desktop from (re)authenticating. Per-socket
  failure close and brute-force lockout are preserved.
- Relay web client XSS eliminated: app.html tab/macro rendering rebuilt with
  createElement/textContent (no macro/tab value reaches innerHTML); inline
  scripts/handlers externalized to /static/app.js and /static/login.js so the
  helmet CSP now uses script-src 'self' (dropped 'unsafe-inline' for scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:07:48 -07:00
parent 0d6f48ca24
commit d07005bde3
8 changed files with 543 additions and 482 deletions
@@ -25,8 +25,13 @@ export function handleWebClientConnection(
socket: WebSocket,
sessionId: string,
connectionManager: ConnectionManager,
sessionManager: SessionManager
sessionManager: SessionManager,
clientIp: string
): void {
// Namespace the brute-force lockout key to the web keyspace and include the
// client IP. This prevents one abusive IP (which knows the shareable session
// id) from locking out other web clients or the desktop for this session.
const throttleKey = `web:${sessionId}:${clientIp}`;
// Check if session exists
const session = sessionManager.getSession(sessionId);
if (!session) {
@@ -62,7 +67,7 @@ export function handleWebClientConnection(
switch (message.type) {
case 'auth': {
const failed = await handleAuth(socket, client, message, sessionId, sessionManager);
const failed = await handleAuth(socket, client, message, sessionId, sessionManager, throttleKey);
if (failed) {
socketAuthFailures++;
if (socketAuthFailures >= config.authMaxSocketFailures) {
@@ -106,10 +111,11 @@ async function handleAuth(
client: WebClientConnection,
message: AuthMessage,
sessionId: string,
sessionManager: SessionManager
sessionManager: SessionManager,
throttleKey: string
): Promise<boolean> {
// Reject early if this session id is currently locked out.
const lockRemaining = getLockoutRemaining(sessionId);
// Reject early if this key is currently locked out.
const lockRemaining = getLockoutRemaining(throttleKey);
if (lockRemaining > 0) {
socket.send(JSON.stringify({
type: 'auth_response',
@@ -123,7 +129,7 @@ async function handleAuth(
const valid = await sessionManager.validatePassword(sessionId, message.password);
if (valid) {
recordSuccess(sessionId);
recordSuccess(throttleKey);
client.authenticated = true;
socket.send(JSON.stringify({
type: 'auth_response',
@@ -132,7 +138,7 @@ async function handleAuth(
logger.debug(`Web client authenticated for session: ${sessionId}`);
return false;
} else {
recordFailure(sessionId);
recordFailure(throttleKey);
socket.send(JSON.stringify({
type: 'auth_response',
success: false,