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:
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 })
|
||||
}
|
||||
Reference in New Issue
Block a user