Files
OwnCord/Client/tests/unit/host-validation.test.ts
T
J3vb fbb85b4d76 fix(b2-8): close the nine B2-tagged ledger findings (#1436)
* docs(b2-8): record the B2-1 pre-squash head and ledger the voice_join ordering hazard

- B2-1 evidence block: PR #1435 pre-squash head 069412db (refs/pull/1435/head), squash 1fe3df79
- Ledger OC-0349 (open, low): the joiner's own voice_state takes the hub queue while the
  rest of the join burst is written directly, so its position on the joiner's socket is
  not ordered (documented in docs/protocol.md during B2-1, not yet fixed)

* fix(client): 2 defect(s) (OC-0311, OC-0315)

OC-0311: scope the voice_leave E2EE participant-left notification to this client's own voice channel — the frame is broadcast to the whole channel read audience, so a peer leaving a channel we merely read could delete their key, clear their verification, and trigger a room-key rotation in our live session.

OC-0315: parse server timestamps with parseTimestamp instead of Date.parse in the reconnect replay gate and the clock-skew sample — the wire form is naive UTC with no 'Z', which Date.parse reads as local time, so an east-of-UTC viewer silently swallowed genuinely live messages.

* fix(voice): 1 defect(s) (OC-0316)

* fix(client): 1 defect(s) (OC-0317)

* fix(plugin): 1 defect(s) (OC-0318)

@
Route every directory-manifest resolution through loadManifestFromDir so
InstallFromZip and scanPluginDirectory apply identical plugin.toml over
plugin.json precedence. A TOML-only zip now installs, and a zip carrying
both manifests is rejected rather than validating one while the loader
later obeys the other.
@

* fix(client): 1 defect(s) (OC-0322)

* fix(ws): 1 defect(s) (OC-0337)

liveVoiceEventsSince's cold-tier fallback handed a cap-truncated window to
resuming clients as if it were complete. The query is oldest-first with a
LIMIT, so a full result means the NEWEST rows were dropped - for a voice
room, quite possibly a peer's voice_leave. Degrade to nil (the documented
best-effort miss) on a cap hit, matching reconnectSelectReplay's guard.

* fix(plugin): 1 defect(s) (OC-0338)

* fix(client): 1 defect(s) (OC-0328)

* docs(b2-8): ledger records, plan evidence and count claims for the nine fixes

- Ledger: OC-0311/0315/0316/0317/0318/0322/0328/0337/0338 -> fixed, each with
  commit, pinning test and revertProof: pass (verify-fixes.mjs 8/8, plus a hand
  RED/GREEN of the wazero-tagged OC-0318 parity test)
- Plan: B2-8 evidence block and status line (B2-0, B2-1, B2-8 landed; B2-2 next)
- Count claims in README, b0-baseline, hp-0-scorecard and the issue register
  follow the ledger (315 fixed / 30 open / 3 declined / 1 duplicate = 349)

* fix(ws): replay a complete cap-sized voice window instead of skipping it (OC-0337 follow-up)

Codex review on #1436: liveVoiceEventsSince decided truncation by
len(persisted) >= coldCap, so a complete window of exactly coldCap rows was
treated as truncated and the supplement returned nil. Fetch coldCap+1 rows
and discard only when the extra row exists. Test-first: the exact-cap case
fails before the change and passes after; the over-cap case still degrades
to nil.
2026-08-28 15:13:49 +00:00

85 lines
3.7 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { isValidHost } from "../../src/lib/hostValidation";
describe("isValidHost", () => {
it("rejects a host containing '@' (identity.ts's OC-0118 scope-key premise)", () => {
// identity.ts's identityScopeKey builds `${userId}@${host}` and relies on
// isValidHost forbidding '@' in any accepted host so a scoped key can
// never collide with a legacy host-only account. If '@' were ever
// accepted here, a host literally equal to "2@chat.example" would be
// indistinguishable from userId 2 scoped to host "chat.example".
expect(isValidHost("2@chat.example")).toBe(false);
expect(isValidHost("user@evil.example:8443")).toBe(false);
});
it("rejects a host longer than 253 characters", () => {
const longHost = "a".repeat(254);
expect(isValidHost(longHost)).toBe(false);
// 253 is the boundary and must still be accepted (paired with a valid
// DNS label shape).
const maxHost = "a".repeat(253);
expect(isValidHost(maxHost)).toBe(true);
});
it("accepts a DNS name, optionally with a port", () => {
expect(isValidHost("chat.example.com")).toBe(true);
expect(isValidHost("chat.example.com:8443")).toBe(true);
});
it("rejects a host containing an underscore (OC-0322: Rust proxies reject it)", () => {
// http_proxy::validate_remote_host and livekit_proxy::validate_remote_host
// only allow is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']' -- JS
// `\w` wrongly includes '_', which would let the client save/accept a
// host neither Rust proxy can ever connect to.
expect(isValidHost("chat_example.com")).toBe(false);
expect(isValidHost("my_server.lan:8443")).toBe(false);
});
it("accepts an IPv4 literal, optionally with a port", () => {
expect(isValidHost("192.168.1.1")).toBe(true);
expect(isValidHost("192.168.1.1:8443")).toBe(true);
});
it("accepts a bracketed IPv6 literal, optionally with a port", () => {
expect(isValidHost("[::1]")).toBe(true);
expect(isValidHost("[::1]:8443")).toBe(true);
expect(isValidHost("[2001:db8::1]")).toBe(true);
});
it("accepts a bare (unbracketed) IPv6 literal", () => {
expect(isValidHost("::1")).toBe(true);
expect(isValidHost("2001:db8::1")).toBe(true);
});
it("rejects a bracketed IPv6 literal with characters before or after the brackets", () => {
// The bracketed-IPv6 regex is anchored at both ends (^...$); without
// those anchors, a bracket pattern anywhere in the string would
// wrongly match.
expect(isValidHost("evil[::1]")).toBe(false);
expect(isValidHost("[::1]evil")).toBe(false);
});
it("rejects a multi-colon host whose characters are not all IPv6-valid", () => {
// More than one colon alone must not be enough to accept a host as a
// bare IPv6 literal -- every character has to be IPv6-valid too (the
// `&&`, not `||`, between the colon-count and character checks).
expect(isValidHost("not:valid:host")).toBe(false);
});
it("rejects a single-colon host with a non-numeric port suffix", () => {
// Exactly one colon must never satisfy the bare-IPv6 branch (which
// requires *more than* one), and it isn't a valid host:port either
// unless the suffix after the colon is numeric.
expect(isValidHost("a:b")).toBe(false);
});
it("rejects a multi-colon host where the IPv6-valid run is only a substring", () => {
// The bare-IPv6 character regex is anchored at both ends -- it has to
// match the whole (multi-colon) host, not just some valid-looking
// substring within or at either end of it.
expect(isValidHost("xyz:ab:cd")).toBe(false);
expect(isValidHost("ab:cd:xyz")).toBe(false);
});
});