feat(gateway): support configurable trusted control-UI origins (#232) - #313
Conversation
Add a narrow, strictly-validated escape hatch for genuine cross-origin/ custom-origin Control UI deployments: operator-supplied origins in data/control-ui-origins.json (or CLAWBOX_CONTROL_UI_ORIGINS_FILE) are merged into the gateway's generated allowedOrigins and honored by the Next.js proxy's redirect-origin reflection, with exact scheme+host+port matching so a configured hostname can't be reflected across other schemes or ports. Same-origin .local/.ts.net/private access is unaffected and normally needs no entry.
|
Warning Review limit reached
Next review available in: 26 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
📝 WalkthroughWalkthroughThe PR adds configurable trusted Control UI origins. It validates and normalizes JSON entries, merges them into gateway configuration, applies exact proxy matching, reloads changed files, documents the option, and adds Python and TypeScript test coverage. ChangesTrusted Control UI origins
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant GatewayPreStart
participant GatewayOrigins
participant GatewayConfig
participant GatewayProxy
Operator->>GatewayPreStart: Provide origins JSON file
GatewayPreStart->>GatewayOrigins: Load and validate origins
GatewayOrigins-->>GatewayPreStart: Normalized origins and warnings
GatewayPreStart->>GatewayConfig: Merge origins with defaults
GatewayProxy->>GatewayOrigins: Reload changed configuration
GatewayProxy-->>Operator: Reflect exact trusted origin
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🦀 ClawReviewYour friendly reef crab, here with the lay of the land. Adds a durable operator escape hatch for registering extra trusted control-UI origins (Tailscale At a glance
Good to know
— ClawReview 🦀. I set the scene; CodeRabbit reviews the code; you decide. Conventions: docs. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/lib/control-ui-origins.ts`:
- Around line 77-123: Update src/lib/control-ui-origins.ts in the URL validation
flow to reject origins when parsed url.hostname differs from the verbatim host
text in the raw input, and check url.username/url.password by presence so empty
credentials are rejected; update scripts/gateway_origins.py in its origin
validator to reject host forms that are neither plain DNS labels nor dotted-quad
IPv4, keeping both validators aligned for numeric and hexadecimal host inputs.
In `@src/lib/gateway-proxy.ts`:
- Around line 112-129: The redirect path in the host-reflection logic must
validate hostHeader before constructing the redirect URL. Update the reflectable
branch around isReflectableHost and NextResponse.redirect to reject out-of-range
or otherwise malformed ports, or safely handle URL construction failures, while
preserving valid configured-host and IPv4 redirects.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 54fdc5ba-72a9-4064-93db-2720179d4319
📒 Files selected for processing (8)
README.mdscripts/gateway-pre-start.shscripts/gateway_origins.pysrc/lib/control-ui-origins.tssrc/lib/gateway-proxy.tssrc/tests/unit/control-ui-origins.test.tssrc/tests/unit/gateway-origins.test.tssrc/tests/unit/gateway-proxy-origins.test.ts
| let url: URL; | ||
| try { | ||
| url = new URL(value); | ||
| } catch { | ||
| return { origin: null, warning: `origin is not a valid URL: ${JSON.stringify(raw)}` }; | ||
| } | ||
|
|
||
| const scheme = url.protocol.slice(0, -1).toLowerCase(); | ||
| if (scheme !== "http" && scheme !== "https") { | ||
| return { origin: null, warning: `origin scheme must be http or https: ${JSON.stringify(raw)}` }; | ||
| } | ||
|
|
||
| if (url.username || url.password) { | ||
| return { origin: null, warning: `origin must not contain credentials: ${JSON.stringify(raw)}` }; | ||
| } | ||
|
|
||
| if (url.pathname !== "" && url.pathname !== "/") { | ||
| return { origin: null, warning: `origin must not contain a path: ${JSON.stringify(raw)}` }; | ||
| } | ||
| if (url.search) { | ||
| return { origin: null, warning: `origin must not contain a query string: ${JSON.stringify(raw)}` }; | ||
| } | ||
| if (url.hash) { | ||
| return { origin: null, warning: `origin must not contain a fragment: ${JSON.stringify(raw)}` }; | ||
| } | ||
|
|
||
| const hostname = url.hostname.toLowerCase(); | ||
| if (!hostname) { | ||
| return { origin: null, warning: `origin is missing a host: ${JSON.stringify(raw)}` }; | ||
| } | ||
|
|
||
| let hostPart: string; | ||
| if (hostname.startsWith("[") && hostname.endsWith("]")) { | ||
| const bare = hostname.slice(1, -1); | ||
| if (!net.isIPv6(bare)) { | ||
| return { origin: null, warning: `origin has an invalid IPv6 host: ${JSON.stringify(raw)}` }; | ||
| } | ||
| hostPart = `[${bare}]`; | ||
| } else { | ||
| if (!HOSTNAME_RE.test(hostname)) { | ||
| return { origin: null, warning: `origin has an invalid host: ${JSON.stringify(raw)}` }; | ||
| } | ||
| if (/^[0-9.]+$/.test(hostname) && !net.isIPv4(hostname)) { | ||
| return { origin: null, warning: `origin has an invalid IPv4 host: ${JSON.stringify(raw)}` }; | ||
| } | ||
| hostPart = hostname; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Loader parity: the TypeScript and Python validators accept different host forms. Both modules validate the same control-ui-origins.json contract and both headers state they must stay in sync. They use different URL parsers. The WHATWG parser rewrites numeric and hex host forms before validation (http://12345 becomes 0.0.48.57, http://0x7f.1 becomes 127.0.0.1), while urllib.parse.urlsplit leaves the host text unchanged. The gateway allowlist and the proxy reflection then disagree for the same file entry.
src/lib/control-ui-origins.ts#L77-L123: reject an origin whose parsedurl.hostnamedoes not appear verbatim in the raw input, and compareurl.username/url.passwordfor presence rather than truthiness sohttp://@example.comis rejected as it is in Python.scripts/gateway_origins.py#L51-L121: add the matching rejection for host forms that are not plain DNS labels or dotted-quad IPv4, so0x7f.1is not accepted verbatim here while the TypeScript loader resolves it to127.0.0.1.
📍 Affects 2 files
src/lib/control-ui-origins.ts#L77-L123(this comment)scripts/gateway_origins.py#L51-L121
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/control-ui-origins.ts` around lines 77 - 123, Update
src/lib/control-ui-origins.ts in the URL validation flow to reject origins when
parsed url.hostname differs from the verbatim host text in the raw input, and
check url.username/url.password by presence so empty credentials are rejected;
update scripts/gateway_origins.py in its origin validator to reject host forms
that are neither plain DNS labels nor dotted-quad IPv4, keeping both validators
aligned for numeric and hexadecimal host inputs.
- redirectToSetup: a default-reflectable host (LAN IP / localhost / mDNS) keeps its broad reflection even when an operator also configures an exact origin for it — configuring https://10.42.0.1 no longer breaks plain http://10.42.0.1 on the SoftAP (which would dead-end at clawbox.local). - control-ui-origins normalizeOrigin: reject the lenient WHATWG forms the Python gateway loader rejects — IPv4 shorthand/integer/octal (2130706433, 127.1, 010.0.0.1) and empty userinfo (http://@host) — so the proxy never trusts an origin the gateway will refuse (half-working deployments). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- gateway-proxy-origins: assert exact-match for a non-default configured host, and (regression) that a default host keeps broad reflection when a matching origin is configured. - control-ui-origins: assert empty-userinfo and IPv4-shorthand origins are rejected like the Python gateway loader. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CodeRabbit: rawHost strips the port before isReflectableHost(), so a Host like
'clawbox.local:99999' reaches the reflect path; new URL('http://clawbox.local:99999/setup')
then throws and the request 500s instead of redirecting. Wrap the reflected
redirect in try/catch and fall through to the canonical origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7be1483 to
63c7bc0
Compare
|
CodeRabbit's two points are both addressed in code:
These also cover the /code-review findings (additive reflection so a configured origin can't strip a default host's LAN/SoftAP reflection). 97 tests green on the Jetson. |
Fixes #232. Supersedes #295 — same feature (@jamesachurchill's commits, authorship preserved), re-homed onto current beta (auto-merged cleanly with the #306 SecretRef and #308 breaker changes to
gateway-proxy.ts/gateway-pre-start.sh) and run through our flow.What
Operators can register extra trusted control-UI origins (VPN / MagicDNS names, a stable HTTPS origin) via
data/control-ui-origins.json. Validated extras merge intocontrolUi.allowedOrigins; every invalid form — bare host, wildcard, path, credentials, wrong scheme, out-of-range port — is rejected so a configured value can never quietly widen cross-origin access. Validation lives in an importable, unit-tested Python module (gateway_origins.py) and a matching TS enforcer (control-ui-origins.ts+gateway-proxy.ts), with a pre-parse filter against the urlsplit-vs-WHATWG parser differential.Why it is needed
isReflectableHost()only reflectsALLOWED_HOSTS+ the mDNS.localname + bare IPv4 — an arbitrary DNS name (Tailscale*.ts.net, a reverse-proxy domain) is bounced, andgateway-pre-start.shrewritescontrolUi.allowedOriginson every boot, so a hand-edit is wiped. This is the only durable, boot-surviving way to register such an origin.Verified on the Jetson (aarch64)
tsc --noEmit: clean (no errors in changed files).Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests