From 885c02bb113c6d9a38d324bac6070afe620e5ae5 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 28 Jun 2026 11:29:22 +0300 Subject: [PATCH 1/2] Validate the Origin header on incoming connections WebSocket handshakes aren't bound by the same-origin policy, so any page a developer has open could otherwise connect to the REPL server and, with takeover, drive it. The server now checks the Origin header and accepts only local origins by default; the new :allowed-origins option widens it. It takes a single origin, a collection, a predicate, or :all. Non-browser clients send no Origin and are unaffected, so the Node integration path keeps working. The origin policy is built before the listen port is bound, so a bad :allowed-origins value fails fast instead of leaking a server with no stop handle. --- src/clj/weasel/repl/server.clj | 73 +++++++++++++++++++++++----- src/clj/weasel/repl/websocket.clj | 3 +- test/clj/weasel/repl/server_test.clj | 46 ++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/clj/weasel/repl/server.clj b/src/clj/weasel/repl/server.clj index fdc02cb..3f09cd0 100644 --- a/src/clj/weasel/repl/server.clj +++ b/src/clj/weasel/repl/server.clj @@ -9,7 +9,8 @@ ; fresh one once the last client leaves; ; nil while the server is stopped :response-fn nil ; (fn [channel data]) - :on-disconnect nil})) ; (fn [channel]) + :on-disconnect nil ; (fn [channel]) + :origin-allowed? nil})) ; (fn [origin]) -> truthy to accept ;; Guards the :clients/:ready invariant (":ready is a realized promise iff ;; :clients is non-empty, and nil iff the server is stopped") so add/remove, @@ -41,9 +42,53 @@ (when-let [f (:on-disconnect @state)] (f channel))))) +(defn- localhost-origin? + "True for an origin served from the local machine, on any port, http or https." + [origin] + (boolean (re-matches #"(?i)https?://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?" origin))) + +(defn ->origin-allowed-fn + "Builds the predicate that accepts or rejects a connection by its `Origin` + header. `allowed` may be: + + :local (or nil) - only origins on the local machine (the secure default) + :all - any origin, which disables the check + a string - a single allowed origin + a collection - an explicit allowlist of exact origin strings + a one-arg fn - called with the origin (which may be nil); you decide + + WebSocket handshakes aren't bound by the same-origin policy, so without this + any page a developer has open could connect to the REPL server. Non-browser + clients don't send an `Origin` header at all, so a missing origin is accepted + by every built-in policy; supply your own predicate to tighten that." + [allowed] + (cond + (or (nil? allowed) (= allowed :local)) #(or (nil? %) (localhost-origin? %)) + (= allowed :all) (constantly true) + (fn? allowed) allowed + (string? allowed) (recur [allowed]) ; a bare origin is a one-element allowlist + (coll? allowed) (let [origins (set allowed)] #(or (nil? %) (contains? origins %))) + :else (throw (IllegalArgumentException. + (str "Invalid :allowed-origins value: " (pr-str allowed)))))) + +(def ^:private default-origin-allowed? + "Fallback policy for a handshake that arrives without a stored one (e.g. one + racing server shutdown): the secure local-only default." + (->origin-allowed-fn :local)) + +(defn- origin-allowed? [request] + (boolean ((or (:origin-allowed? @state) default-origin-allowed?) + (get-in request [:headers "origin"])))) + (defn handler [request] - (if-not (:websocket? request) + (cond + (not (:websocket? request)) {:status 200 :body "Please connect with a websocket!"} + + (not (origin-allowed? request)) + {:status 403 :body "Origin not allowed"} + + :else (with-channel request channel ;; multiple clients may connect; the most recent one wins evaluations, ;; while the others stay connected so their prints still reach the REPL @@ -93,15 +138,20 @@ (swap! state assoc :on-disconnect f)) (defn start - [f & {:keys [ip port] :as opts}] + [f & {:keys [ip port allowed-origins] :as opts}] {:pre [(ifn? f)]} - (locking lock - (swap! state assoc - :server (http/run-server #'handler opts) - :clients [] - :ready (promise) - :response-fn f - :on-disconnect nil))) + ;; build (and validate) the origin policy before binding the port, so a bad + ;; :allowed-origins throws without leaking a running server we can't stop + (let [origin-allowed? (->origin-allowed-fn allowed-origins)] + (locking lock + (swap! state assoc + ;; :allowed-origins is ours, not an http-kit option, so keep it out of opts + :server (http/run-server #'handler (dissoc opts :allowed-origins)) + :clients [] + :ready (promise) + :response-fn f + :on-disconnect nil + :origin-allowed? origin-allowed?)))) (defn stop [] (locking lock @@ -116,7 +166,8 @@ :clients [] :ready nil :response-fn nil - :on-disconnect nil}) + :on-disconnect nil + :origin-allowed? nil}) @state)))) (defn wait-for-client [] diff --git a/src/clj/weasel/repl/websocket.clj b/src/clj/weasel/repl/websocket.clj index bc6b75d..709480e 100644 --- a/src/clj/weasel/repl/websocket.clj +++ b/src/clj/weasel/repl/websocket.clj @@ -92,7 +92,8 @@ (server/start (fn [channel data] (process-message channel (read-string data))) :ip (:ip this) - :port (:port this)) + :port (:port this) + :allowed-origins (:allowed-origins this)) (server/on-disconnect! on-client-disconnect) (let [{:keys [ip pre-connect]} this] (let [port (-> @server/state :server meta :local-port)] diff --git a/test/clj/weasel/repl/server_test.clj b/test/clj/weasel/repl/server_test.clj index 3de1892..a8a0412 100644 --- a/test/clj/weasel/repl/server_test.clj +++ b/test/clj/weasel/repl/server_test.clj @@ -45,6 +45,52 @@ (server/stop) (is (= :woke (deref waiter 1000 ::timeout)) "stop wakes the waiter")))) +(deftest origin-allowlist + (testing "the default :local policy accepts local origins and missing origins" + (let [allow? (#'server/->origin-allowed-fn :local)] + (is (allow? "http://localhost:8080")) + (is (allow? "http://127.0.0.1:3000")) + (is (allow? "https://localhost")) + (is (allow? "http://[::1]:9000")) + (is (allow? nil) "non-browser clients send no Origin header") + (is (not (allow? "http://evil.example.com"))) + (is (not (allow? "http://localhost.evil.com")) "no suffix-match bypass") + (is (not (allow? "http://notlocalhost")) "no prefix-match bypass"))) + (testing "nil is treated the same as :local" + (is ((#'server/->origin-allowed-fn nil) "http://localhost"))) + (testing ":all disables the check" + (is ((#'server/->origin-allowed-fn :all) "http://evil.example.com"))) + (testing "an explicit allowlist accepts only its members (plus missing origins)" + (let [allow? (#'server/->origin-allowed-fn ["https://app.example.com"])] + (is (allow? "https://app.example.com")) + (is (allow? nil)) + (is (not (allow? "https://evil.example.com"))))) + (testing "a bare origin string is treated as a one-element allowlist" + (let [allow? (#'server/->origin-allowed-fn "https://app.example.com")] + (is (allow? "https://app.example.com")) + (is (allow? nil)) + (is (not (allow? "https://evil.example.com"))))) + (testing "a custom predicate gets full control, including over a missing origin" + (let [allow? (#'server/->origin-allowed-fn (fn [o] (= o "app://prod")))] + (is (allow? "app://prod")) + (is (not (allow? nil))))) + (testing "an unusable value is rejected loudly" + (is (thrown? IllegalArgumentException (#'server/->origin-allowed-fn 42))))) + +(deftest handler-rejects-disallowed-origin + (testing "a cross-origin websocket handshake is refused with 403" + (server/start (fn [& _]) :ip "127.0.0.1" :port 0) + (try + (is (= 403 (:status (server/handler {:websocket? true + :headers {"origin" "http://evil.example.com"}})))) + (finally (server/stop))))) + +(deftest start-validates-allowed-origins-before-binding + (testing "a bad :allowed-origins throws without leaving a server running" + (is (thrown? IllegalArgumentException + (server/start (fn [& _]) :ip "127.0.0.1" :port 0 :allowed-origins 42))) + (is (nil? (:server @server/state)) "no server was bound"))) + (deftest stale-disconnect-after-stop-leaves-server-stopped (testing "a late client close after stop does not re-arm the readiness promise" (server/start (fn [& _]) :ip "127.0.0.1" :port 0) From 6af921d995a42c1b31bab34aeb5250f5f108698a Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sun, 28 Jun 2026 11:29:22 +0300 Subject: [PATCH 2/2] Document Origin validation and :allowed-origins --- CHANGES.md | 6 ++++++ README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 366ee90..b4d8fe1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -34,6 +34,12 @@ takes over the REPL) while the others stay connected and their output still reaches the REPL. An evaluation whose target client disconnects mid-flight now reports an error instead of hanging the REPL. +* The server now validates the `Origin` header of incoming WebSocket + connections. By default only local origins (`localhost`, `127.0.0.1`, `[::1]`) + are accepted, closing a hole where any page open in the developer's browser + could connect to the REPL. Use the new `:allowed-origins` repl-env option (a + collection of origins, a predicate, or `:all`) to widen it. Non-browser + clients send no `Origin` header and are unaffected. * Ship a `deps.edn` so the library can be consumed via the Clojure CLI / tools.deps. * Add a GitHub Actions CI pipeline and a basic test suite, including a Node round-trip integration test that exercises the full eval cycle over a real diff --git a/README.md b/README.md index aa764e2..5fffa27 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,36 @@ REPL; the others stay connected and their printed output still reaches the REPL. This pairs naturally with auto-reconnect - a client that drops and comes back simply becomes the active one again. +### Security + +The REPL server validates the `Origin` header of every WebSocket handshake. +WebSocket connections aren't subject to the same-origin policy, so without this +any page a developer happens to have open could connect to the server and take +over the REPL. By default only origins on the local machine (`localhost`, +`127.0.0.1`, `[::1]`, on any port, over http or https) are accepted, which covers +the usual "app served from localhost" setup. Non-browser clients (Node, Deno, +Bun) send no `Origin` header and are always allowed. + +If you serve your app from somewhere else - a LAN IP for testing on a phone, a +custom dev domain - widen the allowlist with `:allowed-origins`: + +```clojure +(weasel.repl.websocket/repl-env + :ip "0.0.0.0" :port 9001 + :allowed-origins ["http://192.168.1.5:8080"]) +``` + +`:allowed-origins` accepts a single origin string, a collection of exact origin +strings, a one-argument predicate that receives the origin (which may be `nil`), +or `:all` to turn the check off entirely. + +Two things worth knowing about the built-in policies. A page opened straight from +disk over `file://` sends `Origin: null` and is rejected - serve it over +`http://localhost` instead, or pass `:all`. And a client that sends no `Origin` +header at all (every non-browser runtime) is always accepted, since browsers are +the only thing the same-origin rule constrains; pass your own predicate if you +need to restrict header-less clients too. + ## Example An example project is included in the `weasel-example` subdirectory of