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.
54 lines
1.9 KiB
TypeScript
54 lines
1.9 KiB
TypeScript
/**
|
|
* 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 })
|
|
}
|