Skip to content
Open
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
1 change: 1 addition & 0 deletions backend/src/app/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
[:oidc-name-attr {:optional true} :string]
[:default-email-domain {:optional true} :string]
[:smb-default-workspace-name {:optional true} :string]
[:platform-domain {:optional true} :string]

[:ldap-attrs-email {:optional true} :string]
[:ldap-attrs-fullname {:optional true} :string]
Expand Down
3 changes: 3 additions & 0 deletions backend/src/app/http.clj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
[app.http.errors :as errors]
[app.http.management :as mgmt]
[app.http.middleware :as mw]
[app.http.portal-logout :as-alias portal-logout]
[app.http.security :as sec]
[app.http.session :as session]
[app.http.websocket :as-alias ws]
Expand Down Expand Up @@ -153,6 +154,7 @@
[::mtx/routes schema:routes]
[::awsns/routes schema:routes]
[::mgmt/routes schema:routes]
[::portal-logout/routes schema:routes]
::session/manager
::setup/props
::db/pool])
Expand Down Expand Up @@ -187,4 +189,5 @@

(::ws/routes cfg)
(::oidc/routes cfg)
(::portal-logout/routes cfg)
(::rpc/routes cfg)]]))
86 changes: 86 additions & 0 deletions backend/src/app/http/portal_logout.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC

(ns app.http.portal-logout
"GET /api/auth/portal-logout — cross-origin redirect-chain entry point
for the foss-server-bundle portal's \"Log out of all apps\" flow.

Clears the auth-token cookie + invalidates the server-side session row
via the same `session/delete-fn` primitive that `auth/logout` RPC uses.
302s to ?next= if its host equals PLATFORM_DOMAIN or is a subdomain;
otherwise returns 200 with cookies still cleared.

CSRF-exempt by design: cross-origin redirect chains cannot share
Penpot's CSRF token. Residual force-logout risk (`<img src=…>`) is
acceptable — only the session itself is lost; the upstream Cognito
session controls real access via oauth2-proxy ForwardAuth, which
re-auths on the next request."
(:require
[app.config :as cf]
[app.http.session :as session]
[cuerdas.core :as str]
[integrant.core :as ig]
[yetti.response :as yres])
(:import
(java.net URI)))

(set! *warn-on-reflection* true)

(defn- with-session-id
"Bridge `::session/session.id` → `::session/id` so `session/delete-fn`
can drop the backing server-side row. Mirrors the helper in
app.http.auth-request used by the same primitive."
[request]
(if-let [sid (some-> request ::session/session :id)]
(assoc request ::session/id sid)
request))

(defn- allowed-next?
"True iff `url` is a safe redirect target:
- scheme is http or https
- host equals PLATFORM_DOMAIN or is a subdomain
Suffix match enforces a dot boundary so `foss.arbisoft.com.evil` does
NOT match `foss.arbisoft.com`. Unset PLATFORM_DOMAIN → false (every
next= rejected)."
[url]
(let [platform-domain (some-> (cf/get :platform-domain) str/lower str/trim
(str/strip-prefix \".\"))]
(when (and platform-domain (seq platform-domain))
(try
(let [uri (URI. url)
scheme (some-> (.getScheme uri) str/lower)
host (some-> (.getHost uri) str/lower)]
(and host
(or (= scheme "http") (= scheme "https"))
(or (= host platform-domain)
(str/ends-with? host (str "." platform-domain)))))
(catch Throwable _ false)))))

(defn- handler
[cfg request]
(let [delete-session! (session/delete-fn cfg)
next-url (some-> request :params :next str/trim)
base-response (if (and next-url (seq next-url) (allowed-next? next-url))
{::yres/status 302
::yres/headers {"Location" next-url}}
{::yres/status 200
::yres/body ""})]
;; delete-fn attaches the Set-Cookie that expires auth-token, in
;; addition to dropping the backing server-side row. Returns the
;; response unchanged when no session is present (e.g. user already
;; logged out by a previous step in the chain).
(delete-session! (with-session-id request) base-response)))

(defmethod ig/assert-key ::routes
[_ params]
(assert (contains? params ::session/manager)
"portal-logout requires ::session/manager"))

(defmethod ig/init-key ::routes
[_ cfg]
["/api/auth/portal-logout"
{:handler (partial handler cfg)
:allowed-methods #{:get}}])
27 changes: 16 additions & 11 deletions backend/src/app/main.clj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.management :as mgmt]
[app.http.portal-logout :as-alias http.portal-logout]
[app.http.session :as session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
Expand Down Expand Up @@ -277,18 +278,22 @@
{::db/pool (ig/ref ::db/pool)
::setup/props (ig/ref ::setup/props)}

::http.portal-logout/routes
{::session/manager (ig/ref ::session/manager)}

:app.http/router
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::setup/props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
::mgmt/routes (ig/ref ::mgmt/routes)
::http.debug/routes (ig/ref ::http.debug/routes)
::http.assets/routes (ig/ref ::http.assets/routes)
::http.ws/routes (ig/ref ::http.ws/routes)
::http.awsns/routes (ig/ref ::http.awsns/routes)}
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::setup/props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
::mgmt/routes (ig/ref ::mgmt/routes)
::http.portal-logout/routes (ig/ref ::http.portal-logout/routes)
::http.debug/routes (ig/ref ::http.debug/routes)
::http.assets/routes (ig/ref ::http.assets/routes)
::http.ws/routes (ig/ref ::http.ws/routes)
::http.awsns/routes (ig/ref ::http.awsns/routes)}

::http.debug/routes
{::db/pool (ig/ref ::db/pool)
Expand Down
70 changes: 70 additions & 0 deletions backend/test/backend_tests/http_portal_logout_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC

(ns backend-tests.http-portal-logout-test
"Unit tests for the ?next= allowlist in the portal-logout endpoint.

These cover the pure-function `allowed-next?` predicate. The handler
itself is exercised via integration in the FOSS bundle: with a real
session it 302s + clears the cookie, with a rejected next= it 200s +
clears the cookie. Verified manually against the devstack."
(:require
[app.config :as cf]
[app.http.portal-logout :as plg]
[clojure.test :as t]))

(defmacro with-platform-domain
[domain & body]
`(with-redefs [cf/get (fn [k# & [default#]]
(if (= k# :platform-domain) ~domain default#))]
~@body))

(t/deftest allowed-next-host-equals-platform-domain
(with-platform-domain "foss.arbisoft.com"
(t/is (true? (#'plg/allowed-next? "https://foss.arbisoft.com/")))))

(t/deftest allowed-next-host-is-subdomain
(with-platform-domain "foss.arbisoft.com"
(t/is (true? (#'plg/allowed-next? "https://pm.foss.arbisoft.com/done")))
(t/is (true? (#'plg/allowed-next? "https://docs.foss.arbisoft.com/x")))))

(t/deftest allowed-next-rejects-other-host
(with-platform-domain "foss.arbisoft.com"
(t/is (false? (#'plg/allowed-next? "https://evil.example/steal")))))

(t/deftest allowed-next-enforces-dot-boundary
;; Suffix match without dot boundary would let foss.arbisoft.com.evil
;; pass as a "subdomain" of foss.arbisoft.com. The endpoint must
;; refuse this.
(with-platform-domain "foss.arbisoft.com"
(t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com.evil/x")))))

(t/deftest allowed-next-rejects-non-http-scheme
;; javascript:, data:, mailto: parse fine as URIs but must never be
;; honoured as redirect targets.
(with-platform-domain "foss.arbisoft.com"
(t/is (false? (#'plg/allowed-next?
"javascript:alert(document.cookie)")))
(t/is (false? (#'plg/allowed-next?
"data:text/html,<script>alert(1)</script>")))))

(t/deftest allowed-next-rejects-everything-when-platform-domain-unset
(with-platform-domain nil
(t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com/"))))
(with-platform-domain ""
(t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com/")))))

(t/deftest allowed-next-rejects-malformed-url
(with-platform-domain "foss.arbisoft.com"
(t/is (false? (#'plg/allowed-next? ":::garbage")))
(t/is (false? (#'plg/allowed-next? "not-a-url")))))

(t/deftest allowed-next-normalises-platform-domain
;; Operators sometimes write ".foss.arbisoft.com" (leading dot) — the
;; predicate should treat that as the same domain.
(with-platform-domain ".foss.arbisoft.com"
(t/is (true? (#'plg/allowed-next? "https://pm.foss.arbisoft.com/")))
(t/is (false? (#'plg/allowed-next? "https://foss.arbisoft.com.evil/")))))
Loading