diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index f9e79f915b6..a4f68c56c42 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -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] diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 1578138d055..a9346e13f03 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -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] @@ -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]) @@ -187,4 +189,5 @@ (::ws/routes cfg) (::oidc/routes cfg) + (::portal-logout/routes cfg) (::rpc/routes cfg)]])) diff --git a/backend/src/app/http/portal_logout.clj b/backend/src/app/http/portal_logout.clj new file mode 100644 index 00000000000..52f7f716f2b --- /dev/null +++ b/backend/src/app/http/portal_logout.clj @@ -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 (``) 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}}]) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 693752080a5..479a653b69a 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -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] @@ -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) diff --git a/backend/test/backend_tests/http_portal_logout_test.clj b/backend/test/backend_tests/http_portal_logout_test.clj new file mode 100644 index 00000000000..724dfc08879 --- /dev/null +++ b/backend/test/backend_tests/http_portal_logout_test.clj @@ -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,"))))) + +(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/")))))