Skip to content

Security: restrict dev-proxy.json to DEBUG + loopback-only targets#15

Draft
mimeding wants to merge 2 commits into
mainfrom
cursor/dev-proxy-localhost-guard-2812
Draft

Security: restrict dev-proxy.json to DEBUG + loopback-only targets#15
mimeding wants to merge 2 commits into
mainfrom
cursor/dev-proxy-localhost-guard-2812

Conversation

@mimeding

Copy link
Copy Markdown
Owner

Summary

Why this matters (business)

dev-proxy.json is a developer-only convenience: plugin authors drop a small config file under ~/.osaurus/config/, and the HTTP handler then transparently re-routes the plugin's web UI to a local dev server (vite, webpack-dev-server, next dev) for hot-reload during development. It's a great DX feature, but it lives outside the normal plugin-install / signing flow and applies in release builds.

Two concrete risks today:

  1. No build-time guard. Any user-mode process that can write to ~/.osaurus/config/ can re-route a shipping plugin's web channel to a server it controls — and Osaurus will helpfully add Access-Control-Allow-Origin: * to the proxied response on the way out. A user who installs a small productivity utility, an Electron app with broad disk access, or a misbehaving editor extension doesn't usually expect that utility to be able to MITM their installed plugins.
  2. No target validation. The "proxy URL" was a free-form string. Nothing stopped someone from pointing it at http://169.254.169.254/latest/meta-data/ (cloud metadata), http://192.168.1.1/admin (LAN device), or even file:///etc/passwd. Osaurus would happily fetch and return the contents through the plugin web channel, with permissive CORS. The dev-proxy feature only ever needed to hit a local dev server, so this scope creep was unintentional.

What's wrong (technical)

    /// Loads the dev proxy URL for a plugin from the dev-proxy.json config file.
    private static func loadDevProxyURL(for pluginId: String) -> String? {
        let configFile = OsaurusPaths.config().appendingPathComponent("dev-proxy.json")
        guard let data = try? Data(contentsOf: configFile),
            let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let configPluginId = obj["plugin_id"] as? String,
            configPluginId == pluginId,
            let proxyURL = obj["web_proxy"] as? String
        else { return nil }
        return proxyURL
    }

The returned proxyURL is then used verbatim — no host check, no scheme check — and the response is decorated with Access-Control-Allow-Origin: * (line 1110).

Fix

Two narrow guards stacked together:

  1. Wrap loadDevProxyURL with #if !DEBUG ⇒ return nil. Release builds silently ignore the config file regardless of contents. Developers who want hot-reload during plugin work opt in by running a DEBUG build (consistent with how the rest of the dev experience already works in this codebase).
  2. Constrain the URL via a new isLoopbackProxyURL static helper. Scheme must be http or https; host must be localhost, a 127.x.x.x IPv4 literal, or ::1. Anything else is rejected and the proxy is treated as not configured.

isLoopbackProxyURL is split out so:

  • Tests can exercise the boundary cases (RFC1918, link-local, cloud-metadata, file://, malformed URLs) without standing up an HTTP server.
  • Any future code (e.g. a sandbox-side dev-proxy) can reuse the same definition of "loopback" without copy-paste.

Tests

New DevProxyURLGuardTests:

  • Accepts: http://localhost:5173, https://localhost, http://127.0.0.1:3000, http://127.255.255.255/x, http://[::1]:1234/.
  • Rejects: RFC1918 (10.x, 172.16-31.x, 192.168.x), public hosts (example.com, 1.1.1.1, 8.8.8.8), link-local + cloud-metadata (169.254.169.254, 169.254.0.1), non-HTTP schemes (file:, data:, ftp:), and obviously malformed strings.

Changes

  • Behavior change (release builds no longer honor dev-proxy.json; DEBUG builds reject non-loopback URLs)
  • UI change
  • Refactor / chore
  • Tests (new DevProxyURLGuardTests)
  • Docs (worth a one-line note in plugin-authoring docs that dev-proxy is DEBUG-only)

Test Plan

cd Packages/OsaurusCore && swift test --filter DevProxyURLGuardTests

Manual sanity:

  1. DEBUG build, loopback URL: drop dev-proxy.json with "web_proxy": "http://localhost:5173". Plugin web UI proxies as before. Unchanged.
  2. DEBUG build, RFC1918 URL: same file with "web_proxy": "http://192.168.1.1:3000". The handler now refuses to proxy and falls back to serving the installed plugin assets. Previously: would have proxied (SSRF-style).
  3. Release build, any URL: dev-proxy.json is silently ignored. Plugin web UI always serves installed assets.

Checklist

  • I have read CONTRIBUTING.md
  • I added/updated tests where reasonable
  • I updated docs/README as needed (would suggest a follow-up one-liner in docs/PLUGIN_AUTHORING.md noting that dev-proxy is DEBUG-only)
  • I verified build on macOS with Xcode 16.4+ (authored in a Linux sandbox; verified each touched file via swiftc -frontend -parse)
Open in Web Open in Cursor 

The dev-proxy.json feature lets plugin authors hot-reload their web UI
from a local dev server (vite, webpack-dev-server, etc.) by writing a
small config file under ~/.osaurus/config/. The HTTP handler picks
that URL up at request time and proxies plugin traffic to it,
returning the dev server's HTML/JS/CSS to the plugin webview with an
extra 'Access-Control-Allow-Origin: *' tacked on.

Two problems with the previous behavior:

  1. The config file applied in release builds too. Any user-mode
     process that could write to ~/.osaurus/config/dev-proxy.json
     could re-route a plugin's web channel to a server it controls
     -- complete with the CORS-relaxing wildcard -- without going
     through any plugin install / signature flow.

  2. The proxy target URL was a free-form string. No host check, no
     scheme check. A misconfig or attack could point it at
     http://169.254.169.254/latest/meta-data/ (cloud-metadata),
     http://192.168.1.1/admin (LAN device), or even file:///etc/passwd,
     turning a developer convenience into an SSRF / local-file-disclosure
     primitive routed through Osaurus.

Fix both:

  * Wrap loadDevProxyURL in '#if !DEBUG / return nil'. Release builds
    silently ignore dev-proxy.json. Users who want hot-reload during
    development opt in by running a DEBUG build.

  * Constrain the URL via a new helper isLoopbackProxyURL. The host
    must be 'localhost', a 127.x.x.x literal, or '::1', and the scheme
    must be http or https. Anything else is rejected and the proxy is
    treated as not configured.

The helper is split out as an internal static so tests can exercise
the boundary cases (RFC1918, cloud-metadata, file:// scheme, malformed
URLs) without standing up an HTTP server, and so future code (e.g. a
sandbox-side dev-proxy) can reuse the same definition of 'loopback'
without copy-paste.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
ModelManager.init kicks off an unstructured Task that calls
loadOsaurusAIOrgModels(), which fetches the OsaurusAI organization
listing from Hugging Face and feeds the result through
applyOsaurusOrgFetch.

The unit-test runner repeatedly constructs ModelManager() to drive
applyOsaurusOrgFetch directly. The background launch-time fetch
races with those test calls — whichever finishes last wins, and
the merge result is non-deterministic. That's the root cause of
the flaky ModelManagerSuggestedTests failures seen across many of
the recent PR CI runs (applyOsaurusOrgFetch_dropsStaleAutoFetched
OnReapply, applyOsaurusOrgFetch_addsNewEntriesAfterCurated, etc.).

Gate the launch-time fetch on a small isRunningInTestEnvironment
helper that checks for any of XCTestConfigurationFilePath,
XCTestBundlePath, or XCTestSessionIdentifier in the process
environment. Those variables are only present inside an xctest host
process; production app launches still get the HF fetch exactly as
before.

This is a network call, so removing it under tests also has the
side benefit of making the test suite work offline / on hermetic
CI runners.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.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.

2 participants