Skip to content

feat(w1): external egress destination mgmt + SSRF guard + key encryption (O3, #17, #18) - #316

Merged
yakimoto merged 4 commits into
mainfrom
feat/w1-o3-dest-mgmt-ssrf-keycrypto
Jul 23, 2026
Merged

yakimoto merged 4 commits into
mainfrom
feat/w1-o3-dest-mgmt-ssrf-keycrypto

Conversation

@yakimoto

@yakimoto yakimoto commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What / why

W1 slice-2a: the security foundation the external-RTMP/SRT restream legs (O1/O2) will consume next. Adds:

  • wre#289 (O3) — destination model + CRUD: src/egress-destinations.ts. POST/GET/GET-{id}/DELETE /v1/egress/destinations, gateway-gated + x-wave-org (same chokepoint every other paid route uses), org-scoped KV (forward egress-dest:{org}:{id} + reverse egress-dest-index:{org}, mirroring the stream-input-org:/org-stream-inputs: shape whep-sources.ts already uses). DELETE mirrors feat(w1): DELETE /v1/whep/sources/{uid} teardown route #310 exactly: absent → idempotent 200, foreign org → 403.
  • chore(guard): sync vendored public-repo-guard to canonical #17 — SSRF guard: src/ssrf-guard.ts. Deny-by-default validation of every create-time destination url, DNS-rebind-safe (resolves the hostname — via injectable resolver, defaulting to Cloudflare DoH since Workers have no raw DNS socket — and checks the RESOLVED ip, not the string).
  • chore(license): standardize on Apache-2.0 per org policy #18 — key encryption at rest: src/dest-key-crypto.ts. AES-256-GCM via WebCrypto (same primitive family already used for HMAC in this repo — event-emitter.ts/rtms-auth.ts/stream-bridge.ts). Only ciphertext+iv ever reach KV; a fresh random IV per encrypt call; decrypt only at the (future) arm call site.

SSRF deny matrix implemented

Reject: loopback (127/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), link-local incl. metadata (169.254/16, 169.254.169.254, fe80::/10), CGNAT (100.64/10), ULA (fc00::/7), 0/8, broadcast/reserved, multicast (v4 224+/v6 ff00::/8), IPv4-mapped IPv6 wrapping a private v4, .local/metadata.google.internal hostnames (pre-resolution), non-allowlisted scheme (only rtmp/rtmps/srt), non-allowlisted port (rtmp:1935, rtmps:443, srt: configurable range), zero-IP resolution, and ANY resolver exception — all fail CLOSED, never open.

Crypto scheme

AES-256-GCM, key from env.DEST_KEY_ENCRYPTION_KEY (base64 32-byte). Redaction: redactDestination() replaces streamKey/passphrase with a [redacted] marker in every response; raw plaintext/ciphertext never logged (grepped every console.* call site in the new files).

Wiring

resolveDestinationForArm(env, org, destId) exported for O1/O2 — decrypts stored key material but does NOT re-run SSRF (documented on the function: the caller must re-validate immediately before dialing, since DNS can rebind between create and connect). egress-arm.ts is untouched.

Ships INERT behind its own EGRESS_DEST_MGMT_ENABLED flag (a sibling of INGRESS_ROUTER_ENABLED, not a reuse — new CRUD+crypto surface gets its own Jake-named ◆ arm) pending the DEST_KEY_ENCRYPTION_KEY Doppler ◆ secret.

Part of W1 slice-2 (external RTMP/SRT restream foundation; O1/O2 consume this).

Test plan

  • test/ssrf-guard.test.ts — full deny matrix (14 deny cases) + allow path (public v4, rtmps:443, resolved-hostname, srt port range) + rebind-safety + fail-closed-on-throw + zero-IP
  • test/dest-key-crypto.test.ts — key validation (absent/bad-base64/wrong-length throws), round-trip, fresh-IV-per-call, tamper detection, wrong-key rejection
  • test/egress-destinations.test.ts — SSRF-reject-persists-nothing, create+redact+ciphertext-in-KV, 503 on unconfigured key, org-scoped list, 404/403 on get, feat(w1): DELETE /v1/whep/sources/{uid} teardown route #310-mirrored delete (idempotent/403/cleanup), resolveDestinationForArm decrypt + null cases, flag-gating fall-through
  • npm testgated: private registry (npm.pkg.github.com) 401s in this sandbox, no node_modules. Typechecked the 3 new files against a DOM-lib substitute tsconfig (workers-types also registry-gated) — clean, no errors.

🤖 Generated with Claude Code


Note

High Risk
Introduces SSRF validation, symmetric encryption of stream credentials, and a new gateway-gated CRUD surface; misconfiguration or future arm-path bugs could expose secrets or allow abusive outbound targets, though the feature ships disabled by default.

Overview
Adds the W1 O3 control-plane foundation for org-owned external RTMP/SRT restream targets, ahead of the O1/O2 dial path.

/v1/egress/destinations (POST create, GET list, GET by id, DELETE) is wired through route-dispatch via maybeHandleEgressDestinations, using the same gateway gate + x-wave-org pattern as whep-sources. Records live in RT_MEETING_ORG KV (egress-dest:{org}:{id} + org index). Create runs validateDestinationUrl (ssrf-guard.ts) before any write; streamKey / passphrase are encrypted with AES-256-GCM (dest-key-crypto.ts, DEST_KEY_ENCRYPTION_KEY) and API responses always redact key material. resolveDestinationForArm is exported for future arm code to decrypt credentials (SSRF re-check left to the caller at dial time).

Everything stays inert until EGRESS_DEST_MGMT_ENABLED is armed (wrangler.toml default "0"). Vitest covers SSRF deny/allow matrix, crypto round-trip/tamper, and CRUD org isolation / idempotent delete.

Reviewed by Cursor Bugbot for commit 07196d6. Configure here.


Summary by cubic

Adds org‑scoped external egress destination CRUD for RTMP/SRT with a deny‑by‑default SSRF guard and AES‑256‑GCM encryption of streamKey/passphrase at rest. Ownership is enforced by id‑keyed records with a defense‑in‑depth org check on list to prevent cross‑org leakage; feature ships inert behind EGRESS_DEST_MGMT_ENABLED (wre#289 O3, #17, #18).

  • New Features

  • Migration

    • Set EGRESS_DEST_MGMT_ENABLED=1 to enable the routes.
    • Provision DEST_KEY_ENCRYPTION_KEY as a base64‑encoded 32‑byte key; without it, creates 503 and nothing is stored.

Written for commit 4113997. Summary will update on new commits.

Review in cubic

…ion (wre#289, #17, #18)

Security foundation for the O1/O2 external-RTMP/SRT restream legs. Adds
/v1/egress/destinations CRUD (POST/GET/GET-by-id/DELETE, org-scoped, gateway-
gated, mirrors whep-sources/#310 auth+ownership shape), a deny-by-default
DNS-rebind-safe SSRF guard for user-supplied rtmp/rtmps/srt destination urls
(ssrf-guard.ts), and AES-256-GCM encrypt-at-rest for streamKey/passphrase via
WebCrypto (dest-key-crypto.ts) with a redacted response shape — plaintext/
ciphertext never leaves the module in a GET/list response or a log line.

New surface ships INERT behind its own EGRESS_DEST_MGMT_ENABLED flag (sibling
of INGRESS_ROUTER_ENABLED, not a reuse) pending the DEST_KEY_ENCRYPTION_KEY
Doppler secret + a Jake-named ◆ arm. egress-arm.ts is untouched; O1/O2 consume
resolveDestinationForArm(env, org, destId) next, re-running the SSRF check at
connect time per its docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 4113997

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e7d7d1b6-69ff-475b-9a3d-c5377f2d2308

📥 Commits

Reviewing files that changed from the base of the PR and between f3079b6 and 4113997.

📒 Files selected for processing (8)
  • src/dest-key-crypto.ts
  • src/egress-destinations.ts
  • src/route-dispatch.ts
  • src/ssrf-guard.ts
  • test/dest-key-crypto.test.ts
  • test/egress-destinations.test.ts
  • test/ssrf-guard.test.ts
  • wrangler.toml
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/w1-o3-dest-mgmt-ssrf-keycrypto

Comment @coderabbitai help to get the list of available commands.

@cursor

cursor Bot commented Jul 23, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_ce46350c-35f1-4e24-8236-47328f444623)

aesKey = await getAesKey(env);
} catch (e) {
// no-silent-failure: an unconfigured/malformed encryption key must 503, never silently store plaintext.
console.error(`egress-destinations create: encryption key unavailable org=${org}: ${(e as Error)?.message}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified a blocking 🔴 issue in your code:
Detected a logger that logs user input without properly neutralizing the output. The log message could contain characters like and and cause an attacker to forge log entries or include malicious content into the logs. Use proper input validation and/or output encoding to prevent log entries from being forged.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>src/egress-destinations.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L299 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 299] request</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L299 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 299] org</a>"]

            v3["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L306 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 306] handleEgressDestinations</a>"]

            v4["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L252 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 252] org</a>"]

            v5["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L278 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 278] createDestination</a>"]

            v6["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L163 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 163] org</a>"]

            v7["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L188 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 188] `</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
            v5 --> v6
            v6 --> v7
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/wave-av/wave-realtime-edge/blob/07196d6a962ccc93d77cafd7f26feb8e5fd94077/src/egress-destinations.ts#L188 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 188] console.error(`egress-destinations create: encryption key unavailable org=${org}: ${(e as Error)?.message}`)</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by console-log-express.

You can view more details about this finding in the Semgrep AppSec Platform.

yakimoto and others added 3 commits July 23, 2026 15:12
…nical form

CRITICAL: checkIpv6's mapped-address check only matched the dotted-quad
textual form (`::ffff:a.b.c.d`) via regex. rtmp/srt are non-special URL
schemes, so WHATWG URL canonicalizes a bracketed IPv4-mapped literal to
hex-group form (`[::ffff:169.254.169.254]` -> `[::ffff:a9fe:a9fe]`) before
ssrf-guard ever sees the hostname string — the regex never matched, the
mapped check no-op'd, and the address was ALLOWED. Confirmed live path to
cloud metadata via `srt://[::ffff:169.254.169.254]:5000`.

Adds `expandIpv6Groups`/`mappedIpv4HexGroupOctets` to detect the
`::ffff:0:0/96` prefix regardless of textual form, reconstruct the embedded
32 bits from the last two hex groups, and re-run `checkIpv4` on them. The
existing dotted-quad path is preserved alongside it.

Also strips a single trailing "." from the hostname before all hostname
comparisons (metadata literal, `.local`, dotted-quad parse) — previously
`169.254.169.254.` / `foo.local.` skipped those fast paths.

test/ssrf-guard.test.ts: adds hex-group-form deny cases (metadata + loopback,
both textual and hex-group), trailing-dot deny cases, and allow-path cases
for legit public mapped addresses (both forms) to prove no over-blocking.
The pre-existing `[::ffff:10.0.0.5]` case (line 27) was silently red-in-
reality under this bug (rtmp is ALSO a non-special scheme) and now passes
for real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ress dest access (CI reds)

- content-policy: annotate the 100.64.0.0/10 CGNAT literal in ssrf-guard.ts and its
  test with `# guard:allow` (legitimate denylist target, not a leaked fleet address).
- egress-destinations.ts: forward KV record was keyed by CALLER-claimed org + id
  (`egress-dest:{org}:{id}`), so a foreign-org GET/DELETE simply missed (404 / idempotent
  200) instead of ever reaching the `record.org !== org` ownership check — the 403 branch
  was dead code, and DELETE on a foreign-org id was a silent no-op masquerading as success.
  Re-keyed the forward record by id ALONE (mirrors whep-sources.ts's
  `STREAM_INPUT_ORG_PREFIX + uid` forward-key pattern) so ownership is checked BEFORE any
  read/delete decision, regardless of which org the caller claims.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-org index only ever holds this org's ids, but re-check record.org===org
after the id-only lookup so a corrupted/mis-written index can never leak another
org's destination — matches getDestination/deleteDestination's explicit guard.
Addresses the background security review's authorization finding on the list path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant