feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)

Parallel module-builder subagents, disjoint file ownership, shared tree:
- T4 src/config.ts        — loadConfig+M1 origins (32 tests)
- T5 src/protocol.ts      — parse/serialize+SESSION_ID_RE+M7 (60 tests)
- T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests)
- T7 src/http/origin.ts   — host+port match, undefined rejected (12 tests)
- T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests)

Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
This commit is contained in:
Yaojia Wang
2026-06-16 08:09:40 +02:00
parent 0e10dc21c3
commit 0fa0b69ce9
15 changed files with 1579 additions and 0 deletions

View File

53
src/http/origin.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* src/http/origin.ts — Origin whitelist check (T7)
*
* Pure function, no side effects. Security-critical:
* this is the CSWSH (Cross-Site WebSocket Hijacking) defence described in
* TECH_DOC §7 and ARCHITECTURE §3.3.
*
* Strategy:
* - Parse the incoming Origin as a URL.
* - Compare scheme + hostname + port against every entry in the allowed list.
* - Both host AND port must match (ARCHITECTURE §3.3: "host+port 双匹配").
* - undefined Origin (non-browser clients: curl, scripts) → reject by default.
* This is the single central policy point — no scattered `if` checks elsewhere.
*/
/**
* Returns true iff `origin` exactly matches one of the `allowed` origins.
*
* Matching is scheme + hostname + port (all three must agree).
* `undefined` → false (non-browser client; default-deny, no scattered if-chains).
*/
export function isOriginAllowed(
origin: string | undefined,
allowed: readonly string[],
): boolean {
// Central policy: undefined Origin is rejected. No other code path handles this.
if (origin === undefined || origin === '') {
return false
}
let incomingUrl: URL
try {
incomingUrl = new URL(origin)
} catch {
// Malformed Origin value → reject.
return false
}
return allowed.some((entry) => {
let allowedUrl: URL
try {
allowedUrl = new URL(entry)
} catch {
return false
}
return (
incomingUrl.protocol === allowedUrl.protocol &&
incomingUrl.hostname === allowedUrl.hostname &&
incomingUrl.port === allowedUrl.port
)
})
}