feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
This commit is contained in:
23
control-panel/src/security/compare.ts
Normal file
23
control-panel/src/security/compare.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Constant-time string comparison (SECURITY-CRITICAL — never use `===` for secrets).
|
||||
*
|
||||
* Mirrors src/http/auth.ts in the base app: both inputs are hashed with SHA-256 to a FIXED 32
|
||||
* bytes, then compared with `crypto.timingSafeEqual`. Hashing-to-fixed-length removes the length
|
||||
* side-channel and sidesteps `timingSafeEqual`'s throw-on-length-mismatch. A missing/empty
|
||||
* candidate short-circuits to `false` before the comparator (it is not a secret-compare oracle).
|
||||
*/
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
export function constantTimeEqual(a: string | undefined, b: string | undefined): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
/** Constant-time byte comparison of two equal-length buffers (false on length mismatch). */
|
||||
export function constantTimeEqualBytes(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
53
control-panel/src/security/cookies.ts
Normal file
53
control-panel/src/security/cookies.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Cookie parse + Set-Cookie serialization — dependency-light (no @fastify/cookie), mirroring the
|
||||
* base app's src/http/auth.ts discipline. Values are returned verbatim (NOT URL-decoded); the
|
||||
* session token uses a cookie-safe charset (base64url + '.') so there is nothing to decode.
|
||||
*/
|
||||
|
||||
/** The panel session cookie name. HttpOnly (JS can't read it → XSS can't exfiltrate it). */
|
||||
export const SESSION_COOKIE_NAME = 'panel_session'
|
||||
|
||||
/**
|
||||
* Parse a raw `Cookie:` header into a name→value map. Malformed pairs (no `=`, empty name) are
|
||||
* ignored; last write wins on duplicate names.
|
||||
*/
|
||||
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
|
||||
const name = part.slice(0, eq).trim()
|
||||
if (name === '') continue
|
||||
out[name] = part.slice(eq + 1).trim()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface SetCookieOptions {
|
||||
readonly value: string
|
||||
readonly maxAgeSec: number
|
||||
readonly secure: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `Set-Cookie` value. Flags: HttpOnly (no JS read), SameSite=Strict (cross-site pages can't
|
||||
* ride the cookie — CSRF/CSWSH defence), Path=/, Max-Age, and Secure ONLY when `secure` (a Secure
|
||||
* cookie is never sent over http:// — forcing it would break a loopback/http operator session).
|
||||
*/
|
||||
export function buildSetCookie(opts: SetCookieOptions): string {
|
||||
const parts = [
|
||||
`${SESSION_COOKIE_NAME}=${opts.value}`,
|
||||
'Path=/',
|
||||
`Max-Age=${opts.maxAgeSec}`,
|
||||
'HttpOnly',
|
||||
'SameSite=Strict',
|
||||
]
|
||||
if (opts.secure) parts.push('Secure')
|
||||
return parts.join('; ')
|
||||
}
|
||||
|
||||
/** Build a `Set-Cookie` that immediately expires the session cookie (logout). */
|
||||
export function buildClearCookie(opts: { secure: boolean }): string {
|
||||
return buildSetCookie({ value: '', maxAgeSec: 0, secure: opts.secure })
|
||||
}
|
||||
32
control-panel/src/security/rate-limit.ts
Normal file
32
control-panel/src/security/rate-limit.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* In-process sliding-window rate limiter keyed by an arbitrary client bucket (per-IP for /login).
|
||||
* Mirrors the base app's control-plane auth-login limiter: a rejected attempt is NOT recorded, so a
|
||||
* throttled client can't push its own window forward. Pure/injectable clock for tests.
|
||||
*/
|
||||
export interface RateLimiter {
|
||||
/** Returns true and records the hit if under the limit; false (no record) when the window is full. */
|
||||
allow(key: string): boolean
|
||||
}
|
||||
|
||||
/** Default per-IP login attempts within the window. */
|
||||
export const DEFAULT_LOGIN_RATE_MAX = 10
|
||||
/** Default login rate window (ms): 15 minutes. */
|
||||
export const DEFAULT_LOGIN_RATE_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
export function createSlidingWindowLimiter(max: number, windowMs: number, now: () => number): RateLimiter {
|
||||
const hits = new Map<string, number[]>()
|
||||
return {
|
||||
allow(key: string): boolean {
|
||||
const ts = now()
|
||||
const cutoff = ts - windowMs
|
||||
const kept = (hits.get(key) ?? []).filter((t) => t > cutoff)
|
||||
if (kept.length >= max) {
|
||||
hits.set(key, kept) // persist the pruned window; do NOT record this rejected attempt
|
||||
return false
|
||||
}
|
||||
kept.push(ts)
|
||||
hits.set(key, kept)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
26
control-panel/src/security/request.ts
Normal file
26
control-panel/src/security/request.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Request-scoped security helpers shared by the route plugins: detect HTTPS (drives the Secure
|
||||
* cookie flag) and read/verify the session cookie off an incoming Fastify request.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify'
|
||||
import { parseCookieHeader, SESSION_COOKIE_NAME } from './cookies.js'
|
||||
import { verifySessionToken } from './session.js'
|
||||
|
||||
/**
|
||||
* True iff the request arrived over HTTPS — directly (`socket.encrypted`) or via a TLS-terminating
|
||||
* edge that set `x-forwarded-proto: https`. Even though the panel binds loopback, a reverse proxy
|
||||
* may front it; honour XFP so the Secure flag is correct on the tunnel path.
|
||||
*/
|
||||
export function isSecureRequest(req: FastifyRequest): 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
|
||||
const socket = req.raw.socket as { encrypted?: boolean } | undefined
|
||||
return socket?.encrypted === true
|
||||
}
|
||||
|
||||
/** True iff the request carries a valid, unexpired session cookie signed with `sessionSecret`. */
|
||||
export function requestIsAuthed(req: FastifyRequest, sessionSecret: string, nowMs: number): boolean {
|
||||
const cookies = parseCookieHeader(req.headers.cookie)
|
||||
return verifySessionToken(sessionSecret, cookies[SESSION_COOKIE_NAME], nowMs)
|
||||
}
|
||||
54
control-panel/src/security/session.ts
Normal file
54
control-panel/src/security/session.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Signed session token — an HMAC-SHA256 MAC over a short-TTL expiry claim. The cookie value is
|
||||
* `<expEpochSec>.<macBase64url>`; the MAC covers the expiry so a client cannot extend its own
|
||||
* session. Verification is constant-time and rejects tampering, wrong-secret, and past-expiry
|
||||
* tokens. Self-contained (node:crypto only) — no external signing dependency.
|
||||
*/
|
||||
import { createHmac } from 'node:crypto'
|
||||
import { constantTimeEqualBytes } from './compare.js'
|
||||
|
||||
/** Session lifetime (seconds): 12h — long enough to avoid constant re-auth, short enough to bound replay. */
|
||||
export const SESSION_TTL_SEC = 12 * 60 * 60
|
||||
|
||||
function macFor(secret: string, payload: string): Buffer {
|
||||
return createHmac('sha256', secret).update(payload, 'utf8').digest()
|
||||
}
|
||||
|
||||
function b64url(buf: Buffer): string {
|
||||
return buf.toString('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a session token that expires `ttlSec` after `nowMs`. The expiry is embedded and signed.
|
||||
*/
|
||||
export function createSessionToken(secret: string, nowMs: number, ttlSec: number = SESSION_TTL_SEC): string {
|
||||
const expSec = Math.floor(nowMs / 1000) + ttlSec
|
||||
const payload = String(expSec)
|
||||
return `${payload}.${b64url(macFor(secret, payload))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `token` is a well-formed, correctly-signed, unexpired session token.
|
||||
* Any structural problem, MAC mismatch, or past-expiry ⇒ false (deny-by-default).
|
||||
*/
|
||||
export function verifySessionToken(secret: string, token: string | undefined, nowMs: number): boolean {
|
||||
if (typeof token !== 'string' || token.length === 0) return false
|
||||
const dot = token.indexOf('.')
|
||||
if (dot <= 0 || dot === token.length - 1) return false
|
||||
const payload = token.slice(0, dot)
|
||||
const presentedMacB64 = token.slice(dot + 1)
|
||||
// Expiry must be a positive integer string; reject anything else before touching crypto.
|
||||
if (!/^\d+$/.test(payload)) return false
|
||||
|
||||
let presentedMac: Buffer
|
||||
try {
|
||||
presentedMac = Buffer.from(presentedMacB64, 'base64url')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const expectedMac = macFor(secret, payload)
|
||||
if (!constantTimeEqualBytes(presentedMac, expectedMac)) return false
|
||||
|
||||
const expSec = Number(payload)
|
||||
return Number.isSafeInteger(expSec) && expSec * 1000 > nowMs
|
||||
}
|
||||
Reference in New Issue
Block a user