Skip to content

security: restrict CORS to localhost origin only - #65

Merged
wesm merged 11 commits into
kenn-io:mainfrom
procrypto:fix/restrict-cors-origin
Feb 27, 2026
Merged

security: restrict CORS to localhost origin only#65
wesm merged 11 commits into
kenn-io:mainfrom
procrypto:fix/restrict-cors-origin

Conversation

@procrypto

Copy link
Copy Markdown
Contributor

Summary

  • Replace Access-Control-Allow-Origin: * with origin validation against the server's configured host:port
  • Both 127.0.0.1 and localhost variants are accepted when binding to either loopback address
  • 0.0.0.0 and :: (bind-all) are treated as "allow loopback origins" since browsers never send Origin: http://0.0.0.0:*
  • Vary: Origin set unconditionally on all /api/ responses to prevent proxy caching issues
  • 5 new tests, 2 updated tests — all 7 CORS tests pass, full server suite passes

Security Impact

Previously, any website could silently make cross-origin requests to the agentsview API while it was running. This allowed:

  • Reading all AI coding session content (code, conversations, file paths)
  • Triggering insight generation (spawning CLI subprocesses)
  • Configuring GitHub tokens and publishing sessions as public gists

The fix restricts CORS to only the origin matching the server's own address.

Changes

  • internal/server/server.go: corsMiddleware now takes an allowedOrigins map and validates the Origin header. New buildAllowedOrigins helper derives the set from config, with special handling for 0.0.0.0/:: bind-all.
  • internal/server/server_test.go: Updated existing CORS tests to send Origin headers. Added TestCORSRejectsUnknownOrigin, TestCORSAllowsLocalhost, TestCORSBindAllInterfaces, TestCORSVaryAlwaysSet.

Test plan

  • TestCORSHeaders — matching origin is reflected
  • TestCORSRejectsUnknownOrigin — foreign origins get no CORS header
  • TestCORSAllowsLocalhost — localhost alias works when bound to 127.0.0.1
  • TestCORSBindAllInterfaces — loopback origins work when bound to 0.0.0.0
  • TestCORSVaryAlwaysSet — Vary: Origin present even for disallowed origins
  • TestCORSPreflight — OPTIONS returns 204
  • TestCORSAllowMethods — Allow-Methods header present
  • Full go test ./internal/server/ passes

@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (393cd9f)

Summary Verdict: The CORS restriction updates improve security by replacing wildcards with an allowlist, but leave the application vulnerable to high-severity CSRF and DNS rebinding attacks, and contain
medium-severity bugs related to IPv6 and default-port origin matching.

High Severity

Cross-Site Request Forgery (CSRF) via Unblocked Simple Requests

  • File: internal/server/server.go, line 276 (inside corsMiddleware)

Description: The corsMiddleware conditionally sets the Access-Control-Allow-Origin header but does not block the request if the origin is not allowed. "Simple" HTTP requests (like a <form> POST) do not trigger a CORS preflight and will still be executed by the server. This allows an
attacker to force a developer's browser to execute state-changing actions via a malicious website. (Note: The TestCORSRejectsUnknownOrigin test currently expects an http.StatusOK, which codifies this insecure behavior).

  • Suggested Remediation: Actively reject requests that contain an unrecognized Origin with an http.StatusForbidden to prevent execution.

Susceptibility to DNS Rebinding Attacks

  • File: internal/server/server.go (Missing middleware)
  • Description: Because the server runs locally, an attacker can set up a custom domain (e.g
    ., attacker.com) with a DNS 'A' record that resolves to 127.0.0.1. The browser considers requests to this domain "same-origin", bypassing CORS preflight restrictions completely. Since the server processes requests without validating the Host header, malicious scripts can gain full read/
    write access to the local API.
  • Suggested Remediation: Implement a middleware that strictly validates the Host header against a whitelist of expected loopback values (e.g., localhost:<port>, 127.0.0.1:<port>, [::1]:<port>,
    and the configured Host) and rejects unrecognized hosts with a 403 Forbidden.

Medium Severity

IPv6 Origin Matching is Incorrect/Incomplete

  • File: internal/server/server.go, lines 211, 222 (in buildAllowedOrigins )
  • Description: Origins are formatted with fmt.Sprintf("http://%s:%d", host, port), which produces invalid URIs for IPv6 literals (e.g., http://::1:port instead of http://[::1]:port). Additionally, ::1 is missing from the loopback and bind-all (0.0.0.0/::) logic, breaking CORS when accessing the app via the IPv6 loopback address.
  • Suggested Remediation: Use net.JoinHostPort to safely format the host and port, and explicitly
    include [::1] / ::1 in the loopback allowlist and switch cases.

Default-Port Origin Normalization Bug (:80 vs no explicit port)

  • File: internal/server/server.go, lines 211, 276

  • Description: Allowlist entries always include :port, but the browser's Origin header omits the default HTTP port (sending http://localhost instead of http://localhost:80). If the application runs on port 80, legitimate origins won't match the allowlist and
    will be blocked.

  • Suggested Remediation: Parse and normalize the incoming Origin before lookup, or add both canonical forms to the allowlist when port == 80.


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (7700c8f)

Summary Verdict: The CORS and CSRF hardening improvements are directionally correct, but introduce a critical DNS rebinding vulnerability and medium-severity flaws regarding empty origins and IPv6 formatting.

High

DNS Rebinding Vulnerability

  • File/Line: internal /server/server.go (around lines 324-344 in corsMiddleware)
  • Issue: The application relies solely on the Origin header for protection and does not validate the Host header, making it vulnerable to DNS Rebinding attacks. An attacker can use a malicious domain resolving
    to 127.0.0.1 to execute same-origin requests against the local API. This allows data exfiltration via GET requests (since !originAllowed && isMutating(r.Method) ignores GET), and mutating requests can also bypass checks if the browser omits the Origin header.
  • Remediation: Implement Host header validation alongside the Origin check. Reject requests where r.Host does not explicitly match expected local hostnames (e.g., localhost, 127.0.0.1, [::1])
    and the configured port. Example:
    host, _, err := net.SplitHostPort(r.Host)
    if err != nil {
        host = r.Host // fallback if no port is provided
    }
    if host != "localhost" && host != "1

27.0.0.1" && host != "::1" && host != s.cfg.Host {
http.Error(w, "Invalid Host header", http.StatusForbidden)
return
}


### Medium

**CSRF Protection Bypass via Empty Origin
**
* **File/Line:** `internal/server/server.go:307`, `internal/server/server.go:340`
* **Issue:** CSRF protection can be bypassed when the `Origin` header is missing. The logic `originAllowed := origin == "" ||
allowedOrigins[origin]` implicitly trusts an empty `Origin`. State-changing requests (`POST`/`PUT`/`PATCH`/`DELETE`) proceed without validation in any client context that omits this header, weakening the CSRF control.
* **Remediation:** For mutating methods and preflight requests, require a non-
empty `Origin` that exists in `allowedOrigins`. If non-browser clients must be supported, gate them with an explicit anti-CSRF mechanism (e.g., a local secret or custom header) rather than defaulting to trust. Add a regression test ensuring mutating requests with no `Origin` return `403`.


**IPv6 Default-Port Origin Formatting Error**
* **File/Line:** `internal/server/server.go:210`
* **Issue:** The `httpOrigin()` function returns `"http://"+host` for port 80. For IPv6 hosts, this produces an invalid
Origin format like `http://::1` instead of `http://[::1]`. Consequently, legitimate browser requests from `http://[::1]` on port 80 may be treated as untrusted, causing mutating API calls to be rejected with a `403`.
* **Remediation:**
When generating no-port origins, bracket IPv6 literals appropriately (or build via `url.URL` / a host-normalization helper).

---
*Synthesized from 4 reviews (agents: codex, gemini | types: default, security)*

@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (a5d67c1)

The PR implements robust CORS and Host header validation to prevent CSRF and DNS rebinding, with one medium-severity issue identified regarding IPv6 host normalization.

Medium

IPv
6 Host header incorrectly normalized for default port 80

  • File: server.go
  • Description: In buildAllowedHosts (), portless hosts are added when port == 80. For IPv6 addresses, browsers send the Host header enclosed in brackets (e.g., [::1]), but the code stores it without brackets (::1). This causes legitimate port-80 IPv6 requests (like
    http://[::1]/...) to be rejected with a 403 Forbidden error.
  • Suggested Fix: Wrap IPv6 hosts in brackets when adding to the allowlist for port 80, mirroring the existing bracket logic in httpOrigin().

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (173f12b)

Verdict: The PR successfully hardens localhost security against DNS Rebinding and CSRF, but introduces a high-severity issue that breaks LAN access and a medium-severity CORS configuration inconsistency.

High

LAN access blocked when bound to all interfaces
File: internal/server/ server.go:210 and internal/server/server.go:286
When the server is bound to all interfaces (0.0.0.0 or ::), the allowlists are statically restricted to loopback addresses and the exact string 0.0.0.0.
This blocks legitimate network clients accessing the API via the machine's actual LAN IP (e.g., 192.168.x.x) or hostname, effectively breaking LAN access.
Suggested fix: When cfg.Host is 0.0.0.0 or
::, either bypass the strict Host and Origin validation or dynamically accept the incoming headers to allow access from other network nodes.

Medium

CORS method list is inconsistent with mutating-method logic
File: /home/roborev/.roborev/clones/wesm/ agentsview/internal/server/server.go:387
isMutating() treats PUT/PATCH as state-changing, but Access-Control-Allow-Methods only advertises GET, POST, DELETE, OPTIONS. If any current or future endpoint uses PUT /PATCH, browser preflight will fail even for allowed origins.
Suggested fix: Include PUT, PATCH in Access-Control-Allow-Methods (or generate this list from a single shared source of truth).


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (fcc9b57)

Summary Verdict: The PR introduces solid Host and CORS protections, but binding to all interfaces (bindAll) currently bypasses these checks, exposing high-severity CSRF and DNS rebinding vulnerabilities.

High Severity

1. CORS and CSRF Bypass on All Interfaces (bindAll)

  • File:
    internal/server/server.go
  • Lines: 382-385
  • Description: When the server is configured to bind to all interfaces (bindAll is true via --host 0.0.0.0), the CORS middleware unconditionally accepts any non-empty
    Origin header (originAllowed := allowedOrigins[origin] || (bindAll && origin != "")). This effectively acts as a wildcard allowlist, completely bypassing CSRF protections. Malicious websites visited by the user can silently execute unauthorized cross-origin mutating requests (POST/DELETE) against the local agentsview instance
    .
  • Suggested Remediation: Do not blindly accept any origin when bindAll is true. Instead, dynamically validate that the Origin is genuinely same-origin by ensuring it matches the request's Host header (e.g., origin == "http://" + r.Host),
    or restrict allowed origins to private LAN IP ranges.

2. DNS Rebinding Vulnerability on All Interfaces (bindAll)

  • File: internal/server/server.go
  • Lines: 252-261
  • Description: The hostCheckMiddleware completely disables Host header validation when bindAll is true (if strings.HasPrefix(r.URL.Path, "/api/") && !bindAll {). This leaves the application vulnerable to DNS Rebinding attacks. An attacker can register a DNS record that resolves to the victim's LAN IP
    address, allowing a malicious site to connect to the local instance with an arbitrary Host header and gain full API access.
  • Suggested Remediation: Do not completely skip Host header checking in bind-all mode. Since LAN clients connect via the machine's IP address, enforce that the Host header must be
    a syntactically valid IP address (or localhost) rather than a public domain name.

Medium Severity

1. Behavioral Regression for Non-Browser Mutating Clients

  • File: internal/server/server.go (line ~407), internal/server/server_test .go (line ~103)
  • Description: Mutating requests are now blocked when the Origin header is missing. While good for browser security, this breaks existing curl commands or scripted clients that do not automatically send Origin headers.
  • Suggested Remediation: Maintain strict checks for
    browser traffic, but provide a safe path for non-browser clients. This could be achieved via a configurable opt-in, API keys, or conditional handling based on the absence of typical browser fetch headers.

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

The CORS middleware previously set Access-Control-Allow-Origin: *,
allowing any website to make cross-origin requests to the API.
This means a malicious website could silently read all session data,
trigger insight generation, configure GitHub tokens, and publish
sessions as public gists — all without user awareness.

Fix: Replace wildcard with origin validation against the configured
host:port. Both 127.0.0.1 and localhost variants are allowed when
binding to either loopback address, since browsers treat them as
distinct origins.

Also adds Vary: Origin header for correct HTTP caching behavior
when the response depends on the request origin.

Tests updated and expanded:
- TestCORSHeaders: verifies matching origin is reflected
- TestCORSRejectsUnknownOrigin: verifies foreign origins get no header
- TestCORSAllowsLocalhost: verifies localhost alias works
- TestCORSPreflight: updated with Origin header
- TestCORSAllowMethods: updated with Origin header
Address two issues identified during security review:

1. When binding to 0.0.0.0 (all interfaces), browsers access
   the server via 127.0.0.1 or localhost — not 0.0.0.0. The
   CORS allowlist now treats 0.0.0.0 and :: as "allow loopback
   origins" so cross-origin requests from the SPA still work.

2. Vary: Origin is now set unconditionally on /api/ responses
   to prevent caching issues where a proxy caches a response
   without CORS headers and serves it to a legitimate origin.

New tests: TestCORSBindAllInterfaces, TestCORSVaryAlwaysSet
Address issues from multi-agent security review:

1. CSRF protection: Mutating requests (POST/PUT/PATCH/DELETE) and
   OPTIONS preflights from unrecognized origins now return 403
   Forbidden instead of executing. This prevents <form>-based
   CSRF attacks that bypass CORS preflight.

2. IPv6 origin formatting: Use net.JoinHostPort to correctly
   produce [::1]:port bracket notation. Add ::1 to the loopback
   allowlist for 0.0.0.0 and :: bind-all cases.

3. Port 80 normalization: Browsers omit :80 from the Origin
   header for default HTTP port. When port is 80, both
   "http://host:80" and "http://host" are now in the allowlist.

Note: DNS rebinding via Host header spoofing remains a separate
concern that requires Host header validation middleware — tracked
as a follow-up, not addressed in this CORS-focused change.

New tests: TestCORSBlocksMutatingFromUnknownOrigin,
TestCORSAllowsMutatingFromKnownOrigin,
TestCORSPreflightRejectsBadOrigin.
Updated: TestCORSBindAllInterfaces adds [::1] origin check.
Address high/medium findings from multi-agent security review:

HIGH — DNS rebinding defense:
Add hostCheckMiddleware that validates r.Host against expected
loopback values before processing /api/ requests. An attacker's
domain resolving to 127.0.0.1 will carry the attacker's domain
as the Host header, which is now rejected with 403.

MEDIUM — Empty Origin CSRF bypass:
Mutating requests (POST/PUT/PATCH/DELETE) now require a non-empty
Origin that matches the allowlist. Previously, originAllowed
treated empty Origin as trusted, allowing CSRF via contexts that
omit the header.

MEDIUM — IPv6 port 80 formatting:
httpOrigin() now brackets IPv6 literals in the portless form
(http://[::1] not http://::1) to match browser behavior.

Tests: 13 CORS/Host tests pass. Full server suite passes.
Updated middleware_test.go to use real listener port for Host
allowlist compatibility.
Browsers send Host: [::1] (with brackets) for IPv6 on port 80.
buildAllowedHosts was storing bare ::1, causing 403 rejections.
When the server binds to all interfaces (0.0.0.0/::), the user
explicitly chose network exposure. Skip Host header validation
and accept any non-empty Origin in this mode so LAN clients
connecting via the machine's real IP are not rejected.

Loopback-only binding (127.0.0.1/localhost/::1) retains strict
Host and Origin validation as before.

Also add PUT and PATCH to Access-Control-Allow-Methods to match
the methods that isMutating() treats as state-changing, preventing
preflight failures if those methods are used by future endpoints.
@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (8934452)

The PR enhances server security with DNS rebinding
protection and CORS hardening, but contains a High-severity CSRF vulnerability for older browsers that must be fixed.

High

File: server.go (Lines 4
25-463)

Issue: CSRF protection bypass for legacy/alternative browsers.
The CORS middleware restricts state-mutating requests from unauthorized origins by checking if the request was initiated by a browser, relying solely on the presence of the Sec-Fetch-Site header (isBrowser := r.Header.Get("Sec-Fetch-Site") != ""). Older browsers (e.g., Safari < 16.4) and privacy-hardened clients do not send Fetch Metadata headers but will send an untrusted Origin header on cross-origin POST requests. Because is Browser evaluates to false, the protection block (if !originAllowed && isMutating(r.Method) && isBrowser) is bypassed, allowing malicious cross-site state changes.

Remediation: Do not gate origin validation solely on Sec-Fetch-Site. Treat the presence of an explicitly disallowed
Origin header as a sufficient indicator to block mutating requests, regardless of isBrowser. Keep the non-browser exception only for truly empty-Origin requests:

if !originAllowed && isMutating(r.Method) && (isBrowser || origin != "") {
    http.Error(
w, "Forbidden", http.StatusForbidden)
    return
}

Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@wesm

wesm commented Feb 27, 2026

Copy link
Copy Markdown
Member

I'm taking over this PR, will rebase and push changes here, thank you for starting this!

@wesm
wesm force-pushed the fix/restrict-cors-origin branch from 8934452 to 445bd2e Compare February 27, 2026 19:48
@roborev-ci

roborev-ci Bot commented Feb 27, 2026

Copy link
Copy Markdown

roborev: Combined Review (445bd2e)

Summary: All reviewers agree the code is clean and no issues were found.

The changes successfully implement robust security controls, including strict Host and Origin header validation, which effectively mitigate DNS rebinding and CSR
F attacks for a local-only application without introducing any regressions.


Synthesized from 4 reviews (agents: codex, gemini | types: default, security)

@wesm
wesm merged commit de4839f into kenn-io:main Feb 27, 2026
6 checks passed
@procrypto
procrypto deleted the fix/restrict-cors-origin branch April 8, 2026 20:21
cursor Bot pushed a commit to diazMelgarejo/periscope that referenced this pull request Jun 1, 2026
## Summary
- Replace `Access-Control-Allow-Origin: *` with origin validation
against the server's configured host:port
- Both `127.0.0.1` and `localhost` variants are accepted when binding to
either loopback address
- `0.0.0.0` and `::` (bind-all) are treated as "allow loopback origins"
since browsers never send `Origin: http://0.0.0.0:*`
- `Vary: Origin` set unconditionally on all `/api/` responses to prevent
proxy caching issues
- 5 new tests, 2 updated tests — all 7 CORS tests pass, full server
suite passes

## Security Impact
Previously, any website could silently make cross-origin requests to the
agentsview API while it was running. This allowed:
- Reading all AI coding session content (code, conversations, file
paths)
- Triggering insight generation (spawning CLI subprocesses)
- Configuring GitHub tokens and publishing sessions as public gists

The fix restricts CORS to only the origin matching the server's own
address.

## Changes
- `internal/server/server.go`: `corsMiddleware` now takes an
`allowedOrigins` map and validates the `Origin` header. New
`buildAllowedOrigins` helper derives the set from config, with special
handling for `0.0.0.0`/`::` bind-all.
- `internal/server/server_test.go`: Updated existing CORS tests to send
Origin headers. Added `TestCORSRejectsUnknownOrigin`,
`TestCORSAllowsLocalhost`, `TestCORSBindAllInterfaces`,
`TestCORSVaryAlwaysSet`.

## Test plan
- [x] `TestCORSHeaders` — matching origin is reflected
- [x] `TestCORSRejectsUnknownOrigin` — foreign origins get no CORS
header
- [x] `TestCORSAllowsLocalhost` — localhost alias works when bound to
127.0.0.1
- [x] `TestCORSBindAllInterfaces` — loopback origins work when bound to
0.0.0.0
- [x] `TestCORSVaryAlwaysSet` — Vary: Origin present even for disallowed
origins
- [x] `TestCORSPreflight` — OPTIONS returns 204
- [x] `TestCORSAllowMethods` — Allow-Methods header present
- [x] Full `go test ./internal/server/` passes

---------

Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants