feat(auth): optional WEBTERM_TOKEN access-token gate (W5)
An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing Origin/CSRF model — never replacing it — and is fully inert when unset. - src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32 bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws. parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled. - WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start. - GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>). - When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a central authGate over all remote HTTP require the cookie. Open: /login, /auth. Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local browser too). - Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged). Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale substitute — on bare ws:// the token is cleartext + replayable; only hardens the TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at --test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red); auth.ts 100% line coverage.
This commit is contained in:
@@ -169,6 +169,31 @@ function parseProjectRoots(raw: string | undefined, homeDir: string): readonly s
|
||||
return Object.freeze(roots.length > 0 ? roots : [homeDir])
|
||||
}
|
||||
|
||||
/**
|
||||
* WEBTERM_TOKEN charset+length policy (w5-access-token). URL/cookie-safe chars
|
||||
* only (no `;`, space, `%`, or control chars) so the value cannot inject a
|
||||
* Set-Cookie/header split or create query-string `%`-encoding ambiguity; the
|
||||
* ≥16 floor multiplies brute-force cost against the /auth rate limiter.
|
||||
*/
|
||||
const WEBTERM_TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/
|
||||
|
||||
/**
|
||||
* Parse WEBTERM_TOKEN. Unset/empty ⇒ undefined (auth DISABLED, LAN zero-config
|
||||
* preserved). When present it must match WEBTERM_TOKEN_RE; invalid ⇒ throw
|
||||
* (fail-fast, like parsePort). The token value is NEVER included in the error
|
||||
* message (secret hygiene).
|
||||
*/
|
||||
function parseWebtermToken(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined || raw === '') return undefined
|
||||
if (!WEBTERM_TOKEN_RE.test(raw)) {
|
||||
throw new Error(
|
||||
'Invalid config: WEBTERM_TOKEN must be 16–512 characters from the ' +
|
||||
'URL/cookie-safe set [A-Za-z0-9._~+/=-] (min length 16).',
|
||||
)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number {
|
||||
if (raw === undefined) return DEFAULT_PORT
|
||||
const n = Number(raw)
|
||||
@@ -329,6 +354,10 @@ export function loadConfig(env: EnvLike): Config {
|
||||
|
||||
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
|
||||
|
||||
// w5-access-token — optional shared access token (SECRET — never logged here).
|
||||
// Unset/empty ⇒ undefined ⇒ auth disabled (LAN zero-config unchanged).
|
||||
const webtermToken = parseWebtermToken(env['WEBTERM_TOKEN'])
|
||||
|
||||
// v0.6 Project Manager — discovery config
|
||||
const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir)
|
||||
const projectScanDepth = parseNonNegativeInt(
|
||||
@@ -474,6 +503,7 @@ export function loadConfig(env: EnvLike): Config {
|
||||
previewBytes,
|
||||
useTmux,
|
||||
allowedOrigins,
|
||||
webtermToken,
|
||||
projectRoots,
|
||||
projectScanDepth,
|
||||
projectScanTtlMs,
|
||||
|
||||
146
src/http/auth.ts
Normal file
146
src/http/auth.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* src/http/auth.ts — optional shared access-token gate (w5-access-token).
|
||||
*
|
||||
* Pure, dependency-light helpers (mirrors the shape/discipline of
|
||||
* src/http/origin.ts). This layer is **ADDITIVE**: it sits *in front of* the
|
||||
* existing Origin/CSWSH defence (src/http/origin.ts) and CSRF guard
|
||||
* (`requireAllowedOrigin` in src/server.ts) — it never replaces or weakens them.
|
||||
*
|
||||
* When `WEBTERM_TOKEN` is unset/empty the whole gate is DISABLED — every helper
|
||||
* that consults `cfg.webtermToken` short-circuits and the server wiring calls
|
||||
* `next()` unconditionally, so behaviour is byte-identical to today (LAN
|
||||
* zero-config preserved). The gate activates only when a token is configured.
|
||||
*
|
||||
* ── HONEST SECURITY BOUNDARY (read before trusting this) ────────────────────
|
||||
* This is a **bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN
|
||||
* `ws://`/`http://` deployment the terminal stream — and therefore this cookie
|
||||
* and token — travel in CLEARTEXT. Anyone sniffing the LAN sees the token and
|
||||
* can REPLAY it (there is no per-request nonce, no channel binding). The token
|
||||
* only meaningfully hardens the relay/tunnel path, where the edge terminates
|
||||
* TLS and the browser speaks `wss://`/`https://`. It is a single shared secret:
|
||||
* no per-user identity, no revocation except changing the env var + restarting,
|
||||
* no lockout beyond rate-limiting. Do NOT read it as "safe on the public
|
||||
* internet" — the TECH_DOC §7 "never port-forward this raw" guidance stands.
|
||||
*/
|
||||
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import type { Config } from '../types.js'
|
||||
|
||||
/** The auth cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate). */
|
||||
export const AUTH_COOKIE_NAME = 'webterm_auth'
|
||||
|
||||
/** Cookie lifetime (seconds). Shipped as a constant (YAGNI — no env dial in v1);
|
||||
* 30 days balances "don't re-auth every session" against a bounded replay window. */
|
||||
const COOKIE_TTL_SEC = 30 * 24 * 60 * 60 // 2592000 (30 days)
|
||||
|
||||
/** Minimal structural view of an incoming request — keeps this module free of
|
||||
* Express/DOM types (a real `http.IncomingMessage`/Express `Request` is
|
||||
* structurally assignable). Used only by `isHttpsRequest`. */
|
||||
export interface RequestLike {
|
||||
readonly headers: Readonly<Record<string, string | string[] | undefined>>
|
||||
// `remoteAddress` is here only so a real `net.Socket` is structurally
|
||||
// assignable (TS weak-type check needs a shared property); `encrypted` (a
|
||||
// `tls.TLSSocket` field) is the one this module actually reads.
|
||||
readonly socket?: { readonly encrypted?: boolean; readonly remoteAddress?: string } | undefined
|
||||
}
|
||||
|
||||
/** True iff auth is enabled: a non-empty token is configured. Unset/empty ⇒
|
||||
* DISABLED (the single central switch — no scattered `if` checks elsewhere). */
|
||||
export function isAuthEnabled(cfg: Pick<Config, 'webtermToken'>): boolean {
|
||||
return typeof cfg.webtermToken === 'string' && cfg.webtermToken.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw `Cookie:` header into a name→value map.
|
||||
* Malformed pairs (no `=`, empty name) are ignored; last write wins on dup names.
|
||||
* Values are returned verbatim (NOT URL-decoded) — our token uses a cookie-safe
|
||||
* charset (validated at config load) so there is nothing to decode.
|
||||
*/
|
||||
export function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
if (header === undefined || header === '') return out
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=')
|
||||
if (eq <= 0) continue // no '=' or empty name → ignore
|
||||
const name = part.slice(0, eq).trim()
|
||||
if (name === '') continue
|
||||
out[name] = part.slice(eq + 1).trim()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison (SECURITY-CRITICAL — never use `===`).
|
||||
*
|
||||
* Both inputs are hashed with SHA-256 to a FIXED 32 bytes, then compared with
|
||||
* `crypto.timingSafeEqual`. Hashing-to-fixed-length is the "fixed-length guard":
|
||||
* it removes the length side-channel (unequal-length secrets no longer leak
|
||||
* their length via timing) AND sidesteps `timingSafeEqual`'s throw-on-length-
|
||||
* mismatch. A present-vs-absent (empty/undefined) candidate short-circuits to
|
||||
* `false` before the comparator — a missing value is not a secret-compare oracle,
|
||||
* not a timing signal to protect.
|
||||
*/
|
||||
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
|
||||
// Boundary guard (input validation): never trust the candidate. An empty or
|
||||
// absent value can never be a match and must not reach the comparator.
|
||||
if (typeof a !== 'string' || typeof b !== 'string') return false
|
||||
if (a.length === 0 || b.length === 0) return false
|
||||
const ha = createHash('sha256').update(a, 'utf8').digest()
|
||||
const hb = createHash('sha256').update(b, 'utf8').digest()
|
||||
return timingSafeEqual(ha, hb) // both are exactly 32 bytes → never throws
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the request's cookie carries a valid auth token.
|
||||
* Disabled config (no token) ⇒ `false` — callers MUST short-circuit on
|
||||
* `isAuthEnabled` first; a disabled gate never treats anyone as "authed".
|
||||
*/
|
||||
export function cookieIsAuthed(
|
||||
cfg: Pick<Config, 'webtermToken'>,
|
||||
cookieHeader: string | undefined,
|
||||
): boolean {
|
||||
const token = cfg.webtermToken
|
||||
if (typeof token !== 'string' || token.length === 0) return false
|
||||
const presented = parseCookieHeader(cookieHeader)[AUTH_COOKIE_NAME]
|
||||
return constantTimeEqual(presented, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `Set-Cookie` header value for a successful auth.
|
||||
*
|
||||
* Flags: `HttpOnly` (XSS can't read it), `SameSite=Strict` (cross-site pages
|
||||
* can't ride the cookie — complements the Origin/CSWSH defence), `Path=/`,
|
||||
* `Max-Age=<ttl>`, and `Secure` **only when `opts.secure`**. The dynamic
|
||||
* `Secure` is required: a `Secure` cookie is never sent over `ws://`/`http://`,
|
||||
* so forcing it would silently break LAN-over-HTTP auth; over the relay
|
||||
* (`x-forwarded-proto: https`) it must be present.
|
||||
*/
|
||||
export function buildSetCookie(
|
||||
cfg: Pick<Config, 'webtermToken'>,
|
||||
opts: { secure: boolean },
|
||||
): string {
|
||||
const parts = [
|
||||
`${AUTH_COOKIE_NAME}=${cfg.webtermToken ?? ''}`,
|
||||
'Path=/',
|
||||
`Max-Age=${COOKIE_TTL_SEC}`,
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
]
|
||||
if (opts.secure) parts.push('Secure')
|
||||
return parts.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the request arrived over HTTPS/WSS — either directly
|
||||
* (`socket.encrypted`) or behind a TLS-terminating edge that set
|
||||
* `x-forwarded-proto: https` (the relay/tunnel path). Drives the dynamic
|
||||
* `Secure` cookie flag above.
|
||||
*/
|
||||
export function isHttpsRequest(req: RequestLike): boolean {
|
||||
const xfp = req.headers['x-forwarded-proto']
|
||||
const proto = Array.isArray(xfp) ? xfp[0] : xfp
|
||||
if (typeof proto === 'string' && proto.split(',')[0]?.trim().toLowerCase() === 'https') {
|
||||
return true
|
||||
}
|
||||
return req.socket?.encrypted === true
|
||||
}
|
||||
158
src/server.ts
158
src/server.ts
@@ -24,9 +24,10 @@ import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import express from 'express'
|
||||
import type { Response } from 'express'
|
||||
import type { NextFunction, Request, Response } from 'express'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import type { WebSocket as WsWebSocket } from 'ws'
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
@@ -34,6 +35,13 @@ import type { IncomingMessage } from 'node:http'
|
||||
import { loadConfig } from './config.js'
|
||||
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
|
||||
import { isOriginAllowed } from './http/origin.js'
|
||||
import {
|
||||
buildSetCookie,
|
||||
constantTimeEqual,
|
||||
cookieIsAuthed,
|
||||
isAuthEnabled,
|
||||
isHttpsRequest,
|
||||
} from './http/auth.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { deriveApprovalPreview } from './http/approval-preview.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
@@ -84,6 +92,16 @@ const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
|
||||
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
|
||||
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
|
||||
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
|
||||
const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard)
|
||||
|
||||
/** Loopback-only Claude Code hook ingest paths the auth gate lets through from a
|
||||
* loopback peer with no cookie (the token is about REMOTE access; hooks run on
|
||||
* the host). Each handler independently re-checks isLoopback (defense in depth). */
|
||||
const LOOPBACK_INGEST_PATHS: ReadonlySet<string> = new Set([
|
||||
'/hook',
|
||||
'/hook/permission',
|
||||
'/hook/status',
|
||||
])
|
||||
|
||||
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
|
||||
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
|
||||
@@ -230,6 +248,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
|
||||
const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
|
||||
const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
|
||||
const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token
|
||||
|
||||
// W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
|
||||
// schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
|
||||
@@ -313,9 +332,134 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
next()
|
||||
})
|
||||
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
|
||||
// ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the
|
||||
// Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒
|
||||
// every request falls straight through `next()` (LAN zero-config unchanged).
|
||||
//
|
||||
// HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws://
|
||||
// the cookie/token travel in cleartext and are replayable by a LAN sniffer;
|
||||
// this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See
|
||||
// src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw").
|
||||
|
||||
// Startup-cached login page (served by GET /login BEFORE the gate, so it is
|
||||
// always reachable while unauthed). Fallback keeps the server alive if the
|
||||
// asset is missing rather than crashing at boot.
|
||||
const loginHtml = ((): string => {
|
||||
try {
|
||||
return readFileSync(path.join(publicDir, 'login.html'), 'utf8')
|
||||
} catch {
|
||||
return '<!doctype html><meta charset="utf-8"><title>Sign in</title><form method="POST" action="/auth"><input type="password" name="token" autofocus><button>Unlock</button></form>'
|
||||
}
|
||||
})()
|
||||
|
||||
/** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */
|
||||
function acceptsHtml(req: Request): boolean {
|
||||
const accept = req.headers['accept']
|
||||
return typeof accept === 'string' && accept.includes('text/html')
|
||||
}
|
||||
|
||||
/** Redirect target for the `?token=` bootstrap: same path with `token` stripped
|
||||
* so no secret is left in browser history / Referer. */
|
||||
function pathWithoutToken(req: Request): string {
|
||||
const u = new URL(req.originalUrl, 'http://localhost')
|
||||
u.searchParams.delete('token')
|
||||
const qs = u.searchParams.toString()
|
||||
return u.pathname + (qs ? `?${qs}` : '')
|
||||
}
|
||||
|
||||
// GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the
|
||||
// CSP is `script-src 'self'`, so the toggle is a server-side class swap).
|
||||
app.get('/login', (req, res) => {
|
||||
const html =
|
||||
req.query['e'] === '1'
|
||||
? loginHtml.replace('ERRSTATE', 'show-error')
|
||||
: loginHtml.replace('ERRSTATE', '')
|
||||
res.type('html').send(html)
|
||||
})
|
||||
|
||||
// POST /auth — always reachable, rate-limited. Accepts urlencoded (native form)
|
||||
// AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒
|
||||
// 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429.
|
||||
app.post(
|
||||
'/auth',
|
||||
express.urlencoded({ extended: false, limit: '1kb' }),
|
||||
express.json({ limit: '1kb' }),
|
||||
(req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? 'unknown'
|
||||
if (!authLimiter(ip, Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
// Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie.
|
||||
if (!isAuthEnabled(cfg)) {
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
const body = (req.body ?? {}) as Record<string, unknown>
|
||||
const candidate = typeof body['token'] === 'string' ? body['token'] : ''
|
||||
const isForm = acceptsHtml(req)
|
||||
if (constantTimeEqual(candidate, cfg.webtermToken)) {
|
||||
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
|
||||
if (isForm) res.redirect(302, '/')
|
||||
else res.status(204).end()
|
||||
return
|
||||
}
|
||||
if (isForm) res.redirect(302, '/login?e=1')
|
||||
else res.status(401).json({ error: 'invalid token' })
|
||||
},
|
||||
)
|
||||
|
||||
// The global auth gate. Runs after the security-headers middleware and BEFORE
|
||||
// express.static + every API route below, so ONE central policy point covers
|
||||
// the whole app (the origin.ts idiom). Allow-list, in order:
|
||||
function authGate(req: Request, res: Response, next: NextFunction): void {
|
||||
// 1. Disabled → everything open (zero-config LAN unchanged).
|
||||
if (!isAuthEnabled(cfg)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission,
|
||||
// /hook/status) has no cookie and must keep working — the token is about
|
||||
// REMOTE access, not the host's own hook side-channel. DEVIATION from the
|
||||
// plan's blanket `isLoopback → next()`: scoped to these three ingest paths
|
||||
// so the gate still protects every OTHER route even from a loopback peer
|
||||
// (a token-enabled deploy then also gates the local browser — strictly
|
||||
// more secure, and testable from a 127.0.0.1 harness).
|
||||
if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie +
|
||||
// redirect to the same path with the token stripped, else → /login.
|
||||
if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') {
|
||||
const ip = req.socket.remoteAddress ?? 'unknown'
|
||||
if (!authLimiter(ip, Date.now())) {
|
||||
res.status(429).end()
|
||||
return
|
||||
}
|
||||
if (constantTimeEqual(req.query['token'], cfg.webtermToken)) {
|
||||
res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
|
||||
res.redirect(302, pathWithoutToken(req))
|
||||
} else {
|
||||
res.redirect(302, '/login')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 4. Valid cookie → allow.
|
||||
if (cookieIsAuthed(cfg, req.headers['cookie'])) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON.
|
||||
if (acceptsHtml(req)) res.redirect(302, '/login')
|
||||
else res.status(401).json({ error: 'authentication required' })
|
||||
}
|
||||
app.use(authGate)
|
||||
|
||||
// Serve the entire public/ directory (including public/build/ esbuild output).
|
||||
// Note: `npm run build:web` must be run before `npm start` to populate public/build/.
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
app.use(express.static(publicDir))
|
||||
|
||||
// ── Claude Code history (O2) — list past sessions for the resume browser ──
|
||||
@@ -1156,6 +1300,16 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
return
|
||||
}
|
||||
|
||||
// 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin
|
||||
// check (never weakens it) and BEFORE the handshake. The browser
|
||||
// auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no
|
||||
// frontend change is needed. Disabled (no token) → this is a no-op.
|
||||
if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Hand off to wss — it completes the handshake and emits 'connection'.
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req)
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface Config {
|
||||
readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
|
||||
readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
|
||||
readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
|
||||
// w5-access-token — optional shared access token gate (ADDITIVE, in front of the
|
||||
// Origin/CSRF model; never replaces it). undefined/empty ⇒ auth DISABLED, so LAN
|
||||
// zero-config is preserved. When set: validated at load (16–512 URL/cookie-safe
|
||||
// chars). SECRET — never log; never return over /config/ui. See src/http/auth.ts.
|
||||
readonly webtermToken: string | undefined; // WEBTERM_TOKEN
|
||||
// v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3)
|
||||
readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir]
|
||||
readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
|
||||
|
||||
Reference in New Issue
Block a user