✨ Honor trusted alternate origins for backend links and OIDC

Add an optional PENPOT_TRUSTED_ORIGINS setting listing alternate origins.
When a request Origin matches, the backend uses it as the public base for
links generated while serving that request: email links, asset URLs,
audit and webhook link fields, and redirects.

A global http middleware stamps the trusted origin on the request and
binds the config public-uri to it for the request extent. OIDC
authorization stores the origin in the signed state so the callback
(a navigation without Origin) can rebuild the token-exchange
redirect_uri and its redirect targets on the alternate domain.

Unlisted origins and requests without Origin are unchanged. Only
bare origins are supported, and server-to-server or queued work
keeps the canonical public-uri.

Closes #11625

AI-assisted-by: deepseek-v4.1-flash
This commit is contained in:
Andrey Antukh 2026-09-23 17:40:27 +00:00
parent c17eb2a804
commit 60e8bf134a
15 changed files with 403 additions and 30 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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]

View File

@ -268,6 +268,7 @@
[_ cfg]
(rr/router
[["" {:middleware [[mw/server-timing]
[mw/trusted-origin]
[sec/sec-fetch-metadata]
[mw/params]
[mw/format-response]

View File

@ -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)})

View File

@ -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)

View File

@ -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"))))))

View File

@ -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)))))

View File

@ -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]

View File

@ -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)))))))

View File

@ -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)

View File

@ -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))))))))

View File

@ -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://<origin>/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

View File

@ -380,6 +380,37 @@ If you're using the official <code class="language-bash">docker-compose.yml</cod
may limit the usage of Penpot; as an example, the clipboard does not work with HTTP.
</p>
### 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://<origin>/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, <code class="language-bash">smtp</code> flag is disabled, the email will be