mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
✨ 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:
parent
c17eb2a804
commit
60e8bf134a
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -268,6 +268,7 @@
|
||||
[_ cfg]
|
||||
(rr/router
|
||||
[["" {:middleware [[mw/server-timing]
|
||||
[mw/trusted-origin]
|
||||
[sec/sec-fetch-metadata]
|
||||
[mw/params]
|
||||
[mw/format-response]
|
||||
|
||||
@ -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)})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))))))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))))))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user