diff --git a/.serena/memories/backend/auth-permissions-product-domains.md b/.serena/memories/backend/auth-permissions-product-domains.md index ddc3aac31a..dd4f026198 100644 --- a/.serena/memories/backend/auth-permissions-product-domains.md +++ b/.serena/memories/backend/auth-permissions-product-domains.md @@ -6,6 +6,7 @@ - Public auth endpoints must explicitly set `::rpc/auth false`; RPC auth defaults to enabled. Session cookie creation/deletion is usually attached as an RPC response transform. - Basic Penpot registration is token staged: prepare/register creates or verifies temporary tokens, then profile creation/session setup is reused by other auth backends. The frontend `/auth/verify-token` flow is a hub for registration confirmation, email change, and invitation tokens. - OIDC-compatible providers share a generic flow: redirect to provider, validate callback/request token, fetch identity data, then login an existing profile or register a new one. Known providers may have hardcoded endpoints; generic OIDC can use discovery/configured endpoints. +- Generic OIDC authorization stores a trusted alternate `Origin` (request `:app.http/trusted-origin`, see `mem:backend/subtleties`) as the `:origin` claim in the signed state; the callback re-applies it to the config public URI so the token-exchange `redirect_uri` and redirect targets stay on the alternate domain. Organization SSO mirrors this through `build-organization-sso-auth-redirect-uri :origin`. - LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow. - LDAP session identity MUST come from the directory-returned email (`info.email`): the profile matching the typed email can differ (aliases, UPNs, multi-valued `mail` attributes) and is used only for lockout checks, never to bind the session. - Account lockout (flag `:account-lockout`, `app.auth.login-lockout`) is Redis-backed and keyed per profile id. Password and LDAP flows check/increment on the profile derived from the typed email and clear on the profile that actually logs in. diff --git a/.serena/memories/backend/subtleties.md b/.serena/memories/backend/subtleties.md index ad6c4321ae..d4bca49f98 100644 --- a/.serena/memories/backend/subtleties.md +++ b/.serena/memories/backend/subtleties.md @@ -30,6 +30,7 @@ - `app.config/config` and `flags` are dynamic `defonce` vars populated from `PENPOT_*` env vars through the shared schema string transformer. Tests and tooling can bind them. - `parse-flags` automatically adds `:disable-secure-session-cookies` when `public-uri` is plain HTTP and not localhost. This changes cookie defaults without an explicit env flag. +- A request `Origin` listed in `:trusted-origins` (`PENPOT_TRUSTED_ORIGINS`, bare origins, comma/whitespace separated) is stamped on the request as `:app.http/trusted-origin` by the http `wrap-trusted-origin` middleware, which binds `app.config/config` with `:public-uri` replaced by that origin for the dynamic extent of the request. Every request-scoped link builder (emails, asset URLs, audit/webhook link fields, redirects) then follows it; unlisted or absent origins keep the canonical `:public-uri` (fail-closed). Background workers run outside the binding and keep the canonical base. - The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags. - Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`. - Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling. diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 1cea8af046..2941afbca5 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -916,7 +916,7 @@ (defn build-organization-sso-auth-redirect-uri "Build the OIDC authorization redirect URI for an organization SSO config. Raises if the config is incomplete or OIDC discovery fails." - [cfg sso & {:keys [dest-url organization-id provider]}] + [cfg sso & {:keys [dest-url organization-id provider origin]}] (let [organization-id (or organization-id (:organization-id sso)) issuer (organization-sso-discovery-uri sso) dest-url (or dest-url (str (cf/get :public-uri)))] @@ -930,6 +930,7 @@ :dest-url dest-url :organization-id organization-id :issuer issuer + :origin origin :exp (ct/in-future "4h")})] (build-auth-redirect-uri oidc-provider state-token)) (catch Throwable cause @@ -1013,6 +1014,11 @@ :provider (:id provider) :invitation-token (:invitation-token params) :external-session-id esid + ;; Trusted alternate origin stamped by the http + ;; trusted-origin middleware; carried through the signed + ;; state so the callback (a navigation without Origin) + ;; can rebuild links and the token-exchange redirect_uri. + :origin (:app.http/trusted-origin request) :props props :exp (ct/in-future "4h")} state (tokens/generate cfg (d/without-nils params)) @@ -1062,7 +1068,15 @@ :organization-id organization-id :organization-name organization-name})))))) -(defn- callback-handler +(defn- trusted-origin-from-state + "Best-effort extraction of the trusted alternate origin carried by the + OIDC state token. Returns nil when the token is missing or invalid." + [cfg state-token] + (try + (:origin (tokens/verify cfg {:token state-token :iss "oidc"})) + (catch Throwable _ nil))) + +(defn- handle-oidc-callback [cfg {:keys [params] :as request}] (if-let [error (get params :error)] (do @@ -1142,6 +1156,16 @@ (l/err :hint "error on process oidc callback" :cause cause)) (redirect-with-error "unable-to-auth" (ex-message cause))))))) +(defn- callback-handler + "Callback entrypoint. The IdP redirect is a browser navigation that + normally carries no Origin, so the http trusted-origin middleware + cannot help here; re-apply the trusted origin stored in the signed + state for the whole callback (token exchange redirect_uri and every + redirect target)." + [cfg {:keys [params] :as request}] + (binding [cf/config (cf/with-public-uri (trusted-origin-from-state cfg (:state params)) cf/config)] + (handle-oidc-callback cfg request))) + (def ^:private schema:routes-params [:map ::session/manager diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index c4f097c373..364247bb06 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -126,6 +126,14 @@ ;; separated list of origins (e.g. "https://plugins.example.com"). [:allowed-origins {:optional true} [::sm/set :string]] + ;; Explicit allowlist of alternate origins the backend may use as the + ;; public base for links generated while serving a request whose + ;; `Origin` header matches. Configured via PENPOT_TRUSTED_ORIGINS as a + ;; comma/whitespace separated list of bare origins + ;; (e.g. "https://alt.example.com"). Fail-closed: origins not listed + ;; keep using :public-uri. + [:trusted-origins {:optional true} [::sm/set :string]] + [:exporter-shared-key {:optional true} :string] [:admin-console-shared-key {:optional true} :string] [:nexus-shared-key {:optional true} :string] @@ -423,6 +431,32 @@ [& segments] (apply join-uri (c/get config :public-uri) segments)) +(defn normalize-origin + "Normalize an origin string for comparison: trim and lowercase it. + Returns nil for nil/blank input." + [s] + (some-> s str/trim str/lower not-empty)) + +(defn trusted-origin? + "Returns true when `origin` matches one of the configured + :trusted-origins. Matching is exact on the normalized origin (trimmed + and lowercased); no paths, wildcards or subdomain matching." + [origin] + (let [origin (normalize-origin origin)] + (boolean + (and (some? origin) + (some #(= origin (normalize-origin %)) + (c/get config :trusted-origins)))))) + +(defn with-public-uri + "Returns a copy of `config` with :public-uri replaced by `base`. When + `base` is nil the config is returned unchanged, so callers can use it + for requests (or tokens) without a trusted origin." + [base config] + (if (some? base) + (assoc config :public-uri base) + config)) + (defn get "A configuration getter. Helps code be more testable." ([key] diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index fa0e04e46c..80c93d3964 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -268,6 +268,7 @@ [_ cfg] (rr/router [["" {:middleware [[mw/server-timing] + [mw/trusted-origin] [sec/sec-fetch-metadata] [mw/params] [mw/format-response] diff --git a/backend/src/app/http/middleware.clj b/backend/src/app/http/middleware.clj index 45f8abecca..79fdb9aea9 100644 --- a/backend/src/app/http/middleware.clj +++ b/backend/src/app/http/middleware.clj @@ -33,6 +33,23 @@ {:name ::server-timing :compile (constantly ymw/wrap-server-timing)}) +(defn wrap-trusted-origin + "When the request carries an `origin` header listed in + :trusted-origins, stamp it on the request and bind the config + :public-uri to it for the dynamic extent of the request. Otherwise the + request is left untouched and the canonical :public-uri is used." + [handler] + (fn [request] + (let [origin (cf/normalize-origin (yreq/get-header request "origin"))] + (if (cf/trusted-origin? origin) + (binding [cf/config (cf/with-public-uri origin cf/config)] + (handler (assoc request ::http/trusted-origin origin))) + (handler request))))) + +(def trusted-origin + {:name ::trusted-origin + :compile (constantly wrap-trusted-origin)}) + (def params {:name ::params :compile (constantly ymw/wrap-params)}) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 0a51654c3a..3ac7f5bbe9 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -733,7 +733,10 @@ (let [redirect-uri (oidc/build-organization-sso-auth-redirect-uri cfg sso :dest-url url - :organization-id organization-id) + :organization-id organization-id + ;; Trusted alternate origin stamped by the + ;; http trusted-origin middleware. + :origin (:app.http/trusted-origin request)) organization-id (or organization-id (:organization-id sso))] (oidc/submit-organization-sso-auth-started-event cfg request profile-id organization-id) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj index 90e1aa18f4..0da100e398 100644 --- a/backend/test/backend_tests/auth_oidc_test.clj +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.exceptions :as ex] [app.common.time :as ct] + [app.common.uri :as u] [app.config :as cf] [app.http.session :as session] [app.setup :as-alias setup] @@ -723,3 +724,92 @@ (t/is (ex/error? e)) (t/is (= :validation (:type (ex-data e)))) (t/is (= :invalid-sso-config (:code (ex-data e)))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; trusted alternate origins +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private trusted-origin-provider + {:id "oidc" + :type "oidc" + :auth-uri "https://idp.example.com/auth" + :client-id "test-client" + :scopes ["openid"]}) + +(t/deftest auth-handler-carries-trusted-origin-in-state + (with-redefs [app.auth.oidc/resolve-provider (constantly trusted-origin-provider) + app.loggers.audit/extract-utm-params (constantly {}) + app.loggers.audit/get-external-session-id (constantly nil)] + (t/testing "trusted origin is stored in the state and used for redirect_uri" + (let [request (assoc {:params {}} :app.http/trusted-origin "https://alt.example.com") + result (binding [cf/config {:public-uri "https://alt.example.com" + :trusted-origins #{"https://alt.example.com"}}] + (#'oidc/auth-handler base-cfg request)) + uri (get-in result [::yres/body :redirect-uri]) + query (u/query-string->map (:query (u/uri uri))) + claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})] + (t/is (= "https://alt.example.com" (:origin claims))) + (t/is (= "https://alt.example.com/api/auth/oidc/callback" (:redirect_uri query))))) + + (t/testing "without a trusted origin no :origin claim is stored" + (let [result (binding [cf/config {:public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"}}] + (#'oidc/auth-handler base-cfg {:params {}})) + uri (get-in result [::yres/body :redirect-uri]) + query (u/query-string->map (:query (u/uri uri))) + claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})] + (t/is (not (contains? claims :origin))) + (t/is (= "http://localhost:3449/api/auth/oidc/callback" (:redirect_uri query))))))) + +(t/deftest build-organization-sso-auth-redirect-uri-stores-trusted-origin + (let [sso {:organization-id test-organization-id + :issuer "https://idp.example.com" + :client-id "test-client" + :client-secret "test-secret"} + provider trusted-origin-provider + uri (binding [cf/config {:public-uri "https://alt.example.com" + :trusted-origins #{"https://alt.example.com"}}] + (oidc/build-organization-sso-auth-redirect-uri + base-cfg sso + :dest-url "https://alt.example.com/#/dashboard" + :organization-id test-organization-id + :provider provider + :origin "https://alt.example.com")) + query (u/query-string->map (:query (u/uri uri))) + claims (tokens/verify base-cfg {:token (:state query) :iss "oidc"})] + (t/is (= "https://alt.example.com" (:origin claims))) + (t/is (= "https://alt.example.com/#/dashboard" (:dest-url claims))) + (t/is (= "https://alt.example.com/api/auth/oidc/callback" (:redirect_uri query))))) + +(t/deftest callback-applies-stored-trusted-origin + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + seen (atom nil) + state (make-state-token cfg {:origin "https://alt.example.com"}) + request (default-request cfg :state state)] + (binding [cf/config {:public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"}}] + (with-redefs [app.auth.oidc/resolve-provider (constantly {:type "oidc" :id "oidc"}) + app.auth.oidc/get-info (fn [_ _ _ _] + (reset! seen (cf/get :public-uri)) + {:email "u@e.com" :fullname "U" + :backend "oidc" :email-verified false + :props {}}) + app.auth.oidc/get-profile (constantly test-profile) + app.auth.oidc/update-profile-with-info (fn [_ profile _] profile) + app.loggers.audit/submit (constantly nil)] + (let [result (#'oidc/callback-handler cfg request) + loc (redirect-location result)] + (t/is (= "https://alt.example.com" @seen)) + (t/is (.contains loc "https://alt.example.com?screen=auth-verify-token&"))))))) + +(t/deftest callback-error-uses-stored-trusted-origin + (let [cfg (dissoc base-cfg :app.email/blacklist :app.email/whitelist) + state (make-state-token cfg {:origin "https://alt.example.com"}) + request (assoc (default-request cfg :state state) + :params {:state state :error "access_denied"})] + (binding [cf/config {:public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"}}] + (let [result (#'oidc/callback-handler cfg request) + loc (redirect-location result)] + (t/is (.contains loc "https://alt.example.com?screen=auth-login&")) + (t/is (.contains loc "error=unable-to-auth")))))) diff --git a/backend/test/backend_tests/config_test.clj b/backend/test/backend_tests/config_test.clj index 131370f603..10f387ad96 100644 --- a/backend/test/backend_tests/config_test.clj +++ b/backend/test/backend_tests/config_test.clj @@ -38,3 +38,34 @@ (t/testing "a leading slash would drop the subpath, so it fails fast" (t/is (thrown? AssertionError (cf/join-uri "https://example.com/penpot" "/assets/by-id/123"))))) + +(t/deftest trusted-origins-decodes-from-env-string + (t/testing "comma and whitespace separated origins become a set" + (let [config (cf/decode-config {:tenant "default" + :host "localhost" + :public-uri "http://localhost:3449" + :trusted-origins "https://a.example.com, https://b.example.com https://c.example.com"})] + (t/is (= #{"https://a.example.com" "https://b.example.com" "https://c.example.com"} + (:trusted-origins config)))))) + +(t/deftest trusted-origin?-checks-normalized-allowlist + (with-redefs [cf/config (assoc cf/config :trusted-origins #{"https://alt.example.com"})] + (t/testing "exact and normalized matches" + (t/is (cf/trusted-origin? "https://alt.example.com")) + (t/is (cf/trusted-origin? " HTTPS://Alt.Example.com "))) + (t/testing "nil, blank, unlisted, subdomain and path variants are rejected" + (t/is (not (cf/trusted-origin? nil))) + (t/is (not (cf/trusted-origin? ""))) + (t/is (not (cf/trusted-origin? "https://other.example.com"))) + (t/is (not (cf/trusted-origin? "https://sub.alt.example.com"))) + (t/is (not (cf/trusted-origin? "https://alt.example.com/penpot")))))) + +(t/deftest trusted-origin?-without-configuration + (with-redefs [cf/config (assoc cf/config :trusted-origins nil)] + (t/is (not (cf/trusted-origin? "https://alt.example.com"))))) + +(t/deftest with-public-uri-overrides-base + (let [config {:public-uri "http://localhost:3449" :host "localhost"}] + (t/is (= "https://alt.example.com" + (:public-uri (cf/with-public-uri "https://alt.example.com" config)))) + (t/is (= config (cf/with-public-uri nil config))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index a32d4facb8..598d53a2af 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -20,6 +20,8 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] + [app.http :as-alias http] + [app.http.middleware :as mw] [app.main :as main] [app.media] [app.media :as-alias mtx] @@ -446,50 +448,77 @@ remote-addr server-name server-port scheme protocol path query ssl-client-cert))) -(defn- prepare-rpc-params - [data] +(defn- build-rpc-params + "Builds the params map for a command from `data` and a ring request. + A plain-map `:app.http/request` in `data`'s metadata is honored, so + tests can inject headers, remote address and auth context (e.g. + :app.http/auth-key-id). Non-map requests (reify IRequest stubs) fall + back to `request`." + [data request] (let [params (reduce-kv (fn [params k v] (if (qualified-keyword? k) (assoc params k v) params)) {} (dissoc data ::type)) - ;; Honor a caller-supplied request map from the data metadata so - ;; tests can inject headers, remote address and auth context (e.g. - ;; :app.http/auth-key-id). Non-map requests (reify IRequest stubs) - ;; fall back to the dummy request, which carries the body params - ;; the validation wrapper reads. supplied (-> data meta :app.http/request) request (if (map? supplied) (assoc supplied :params (merge (:params supplied) (d/without-qualified data))) - (assoc (make-dummy-request) - :params (d/without-qualified data)))] + (assoc request :params (d/without-qualified data)))] (-> params (assoc :app.rpc/request-at (ct/now)) (with-meta {:app.http/request request})))) -(defn command! - [{:keys [::type] :as data}] - (let [[mdata method-fn] (get-in *system* [:app.rpc/methods type])] - (when-not method-fn - (ex/raise :type :assertion - :code :rpc-method-not-found - :hint (str/ffmt "rpc method '%' not found" (name type)))) +(defn- prepare-rpc-params + ([data] (prepare-rpc-params data {})) + ([data headers] + (build-rpc-params data (make-dummy-request {:headers (or headers {})})))) - (let [params (prepare-rpc-params data)] - (try-on! (method-fn params))))) +(defn command! + ([data] + (command! data {})) + ([{:keys [::type] :as data} {:keys [headers]}] + (let [[mdata method-fn] (get-in *system* [:app.rpc/methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "rpc method '%' not found" (name type)))) + + (let [params (prepare-rpc-params data headers)] + (try-on! (method-fn params)))))) (defn management-command! - [{:keys [::type] :as data}] - (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] - (when-not method-fn - (ex/raise :type :assertion - :code :rpc-method-not-found - :hint (str/ffmt "management rpc method '%' not found" (name type)))) - (let [params (prepare-rpc-params data)] - (try-on! (method-fn params))))) + ([data] + (management-command! data {})) + ([{:keys [::type] :as data} {:keys [headers]}] + (let [[_ method-fn] (get-in *system* [:app.rpc/management-methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "management rpc method '%' not found" (name type)))) + (let [params (prepare-rpc-params data headers)] + (try-on! (method-fn params)))))) + +(defn command-through-middleware! + "Runs an RPC method wrapped in the HTTP trusted-origin middleware, so + the request `Origin` header can override the config :public-uri. + + Accepts the same data map as `command!` plus an options map with + `:headers` for the dummy request." + ([data] + (command-through-middleware! data {})) + ([{:keys [::type] :as data} {:keys [headers]}] + (let [[_ method-fn] (get-in *system* [:app.rpc/methods type])] + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "rpc method '%' not found" (name type)))) + (let [handler (fn [request] + (try-on! (method-fn (build-rpc-params data request))))] + ((mw/wrap-trusted-origin handler) + (make-dummy-request {:headers (or headers {})})))))) (defn run-task! ([name] diff --git a/backend/test/backend_tests/http_middleware_test.clj b/backend/test/backend_tests/http_middleware_test.clj index 1651d92e0a..a2526d03a3 100644 --- a/backend/test/backend_tests/http_middleware_test.clj +++ b/backend/test/backend_tests/http_middleware_test.clj @@ -669,3 +669,41 @@ (t/is (= "safe user-facing hint" (:hint body))) (t/is (nil? (:state body))) (t/is (nil? (:path body))))) + +(t/deftest trusted-origin-middleware-binds-public-uri + (let [seen (atom nil) + handler (mw/wrap-trusted-origin + (fn [request] + (reset! seen {:public-uri (cf/get :public-uri) + :stamp (::http/trusted-origin request)}) + {}))] + (with-redefs [cf/config (assoc cf/config + :public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"})] + (t/testing "trusted origin overrides the public base and stamps the request" + (reset! seen nil) + (handler (th/make-dummy-request {:headers {"origin" "https://alt.example.com"}})) + (t/is (= "https://alt.example.com" (:public-uri @seen))) + (t/is (= "https://alt.example.com" (:stamp @seen)))) + + (t/testing "origin is normalized before matching and use" + (reset! seen nil) + (handler (th/make-dummy-request {:headers {"origin" " HTTPS://Alt.Example.com "}})) + (t/is (= "https://alt.example.com" (:public-uri @seen))) + (t/is (= "https://alt.example.com" (:stamp @seen)))) + + (t/testing "untrusted origin is ignored" + (reset! seen nil) + (handler (th/make-dummy-request {:headers {"origin" "https://evil.example.com"}})) + (t/is (= "http://localhost:3449" (:public-uri @seen))) + (t/is (nil? (:stamp @seen)))) + + (t/testing "missing origin is ignored" + (reset! seen nil) + (handler (th/make-dummy-request {})) + (t/is (= "http://localhost:3449" (:public-uri @seen))) + (t/is (nil? (:stamp @seen)))) + + (t/testing "the binding does not leak past the request" + (handler (th/make-dummy-request {:headers {"origin" "https://alt.example.com"}})) + (t/is (= "http://localhost:3449" (cf/get :public-uri))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 978a91ca19..d03969f6fd 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -175,13 +175,43 @@ (t/is (= {:authorized false :redirect-uri redirect-uri} (:result out))) - (t/is (= #{:dest-url :organization-id} (set (keys @redirect-options)))) + (t/is (= #{:dest-url :organization-id :origin} (set (keys @redirect-options)))) (t/is (= "https://penpot.example.com/#/workspace" (str (:dest-url @redirect-options)))) (t/is (nil? (:organization-id @redirect-options))) + (t/is (nil? (:origin @redirect-options))) (t/is (= {:profile-id (:id team-owner) :organization-id organization-id} @started-event))))))) +(t/deftest check-nitrate-sso-passes-trusted-origin + (let [team-owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id team-owner)}) + organization-id (uuid/random) + redirect-options (atom nil) + params {::th/type :check-nitrate-sso + ::rpc/profile-id (:id team-owner) + :team-id (:id team) + :url "https://alt.example.com/#/workspace"}] + (binding [cf/flags (conj cf/flags :admin-console)] + (with-redefs [cf/config (assoc cf/config + :public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"}) + nitrate/call + (active-sso-call-mock + (:id team) + organization-id + (:id team-owner)) + oidc/build-organization-sso-auth-redirect-uri + (fn [_cfg _sso & options] + (reset! redirect-options (apply hash-map options)) + "https://idp.example.com/authorize") + oidc/submit-organization-sso-auth-started-event + (constantly nil)] + (let [out (th/command-through-middleware! + params {:headers {"origin" "https://alt.example.com"}})] + (t/is (th/success? out)) + (t/is (= "https://alt.example.com" (:origin @redirect-options)))))))) + (t/deftest check-nitrate-sso-reports-redirect-failure (let [profile (th/create-profile* 1 {:is-active true}) organization-id (uuid/random) diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index ed6520b815..a156faa374 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1751,3 +1751,41 @@ ::rpc/profile-id (:id stranger)})] (t/is (th/success? out)) (t/is (nil? (some #(= (:id project) (:id %)) (:result out)))))))) + +(t/deftest create-team-invitations-uses-trusted-origin-as-link-base + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails ["invitee@example.com"]}] + + (with-redefs [cf/config (assoc cf/config + :public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"})] + (let [out (th/command-through-middleware! + data {:headers {"origin" "https://alt.example.com"}}) + email (-> @mock :call-args-list first first)] + (t/is (th/success? out)) + (t/is (= "https://alt.example.com" (:public-uri email)))))))) + +(t/deftest create-team-invitations-ignores-untrusted-origin + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [owner (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + data {::th/type :create-team-invitations + ::rpc/profile-id (:id owner) + :team-id (:id team) + :role :editor + :emails ["invitee@example.com"]}] + + (with-redefs [cf/config (assoc cf/config + :public-uri "http://localhost:3449" + :trusted-origins #{"https://alt.example.com"})] + (let [out (th/command-through-middleware! + data {:headers {"origin" "https://evil.example.com"}}) + email (-> @mock :call-args-list first first)] + (t/is (th/success? out)) + (t/is (= "http://localhost:3449" (:public-uri email)))))))) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index e667bc3049..496e16ff0f 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -28,6 +28,11 @@ x-flags: &penpot-flags x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 + ## Optional: alternate origins the backend may use as the base for generated + ## links (emails, assets, redirects) when a request Origin matches. Bare + ## origins only, comma/whitespace separated; each must register its OIDC + ## callback URL (https:///api/auth/oidc/callback) at the IdP. + # PENPOT_TRUSTED_ORIGINS: https://alt.example.com, https://staging.example.com x-body-size: &penpot-http-body-size # Max body size diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index 95412109c9..53710d47ea 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -380,6 +380,37 @@ If you're using the official docker-compose.yml +### Trusted alternate origins + +When Penpot is served on an alternate domain (a temporary deployment, a canary, or a +migration between domains) while `PENPOT_PUBLIC_URI` still points at the canonical +domain, the backend keeps building absolute links from `PENPOT_PUBLIC_URI`. Users +browsing the alternate domain then get links (for example in invitation emails) that +send them to the canonical deployment. + +`PENPOT_TRUSTED_ORIGINS` lets operators list the alternate origins the backend may use +instead. When a request carries an `Origin` header that matches one of the listed +origins, the backend uses that origin as the base for the links it generates while +serving the request (transactional emails, asset URLs, audit and webhook link fields, +and redirects). Origins that are not listed, and requests without an `Origin` header, +keep using `PENPOT_PUBLIC_URI`. + +```bash +# Backend +PENPOT_TRUSTED_ORIGINS: https://alt.penpot.mycompany.com, https://staging.penpot.mycompany.com +``` + +- The value is a comma or whitespace separated list of bare origins + (`scheme://host[:port]`). Paths are not supported. +- The setting is optional and backend-only. With no value, behavior is unchanged + (fail-closed). +- Each alternate origin must register its own OIDC callback URL + (`https:///api/auth/oidc/callback`) at the identity provider. If you use SSO + logout, its post-logout redirect URL must be registered as well. +- Notifications sent later by background workers keep the canonical `PENPOT_PUBLIC_URI`, + because they run outside the request that triggered them. The same applies to + server-to-server calls (for example the management API) that carry no `Origin`. + ## Email configuration By default, smtp flag is disabled, the email will be