Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 62 additions & 11 deletions src/clj/weasel/repl/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -116,7 +166,8 @@
:clients []
:ready nil
:response-fn nil
:on-disconnect nil})
:on-disconnect nil
:origin-allowed? nil})
@state))))

(defn wait-for-client []
Expand Down
3 changes: 2 additions & 1 deletion src/clj/weasel/repl/websocket.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
46 changes: 46 additions & 0 deletions test/clj/weasel/repl/server_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading