mirror of
https://github.com/penpot/penpot.git
synced 2026-05-06 16:48:48 +00:00
Merge remote-tracking branch 'origin/main' into staging
This commit is contained in:
commit
14a0660352
11
AGENTS.md
11
AGENTS.md
@ -32,6 +32,17 @@ precision while maintaining a strong focus on maintainability and performance.
|
||||
5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects
|
||||
`.gitignore` by default.
|
||||
|
||||
## Changelogs
|
||||
|
||||
The project has two changelogs:
|
||||
|
||||
- **Main project changelog**: `CHANGES.md` (root of the repository). Tracks changes for the core Penpot application (backend, frontend, common, render-wasm, exporter, mcp).
|
||||
- **Plugins changelog**: `plugins/CHANGELOG.md`. Tracks changes for the plugins subproject only.
|
||||
|
||||
When making changes, add a changelog entry to the appropriate file under the
|
||||
`## <version> (Unreleased)` section in the correct category
|
||||
(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`).
|
||||
|
||||
## GitHub Operations
|
||||
|
||||
To obtain the list of repository members/collaborators:
|
||||
|
||||
21
CHANGES.md
21
CHANGES.md
@ -1,15 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.17.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
## 2.16.0 (Unreleased)
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
@ -132,6 +122,7 @@
|
||||
- Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070)
|
||||
- Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183)
|
||||
|
||||
|
||||
## 2.15.0 (Unreleased)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
@ -145,6 +136,16 @@
|
||||
- Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296)
|
||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||
- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162)
|
||||
- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041)
|
||||
- Fix false “text editing” warning when applying tokens [Github #6346](https://github.com/penpot/penpot/issues/9346)
|
||||
|
||||
|
||||
## 2.14.5
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380)
|
||||
|
||||
|
||||
## 2.14.4
|
||||
|
||||
|
||||
@ -258,24 +258,44 @@
|
||||
(validate-register-attempt! cfg params)
|
||||
|
||||
(let [email (profile/clean-email email)
|
||||
profile (profile/get-profile-by-email pool email)
|
||||
props (-> (audit/extract-utm-params params)
|
||||
(cond-> (:accept-newsletter-updates params)
|
||||
(assoc :newsletter-updates true)))
|
||||
params {:email email
|
||||
:fullname fullname
|
||||
:password (:password params)
|
||||
:invitation-token (:invitation-token params)
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 7})
|
||||
:props props}
|
||||
params (d/without-nils params)
|
||||
token (tokens/generate cfg params)]
|
||||
profile (profile/get-profile-by-email pool email)]
|
||||
|
||||
(-> {:token token}
|
||||
(with-meta {::audit/profile-id uuid/zero}))))
|
||||
;; SECURITY: refuse to issue a prepared-register token when an active
|
||||
;; profile already exists for this email.
|
||||
;;
|
||||
;; Active accounts must use the standard login flow; existing-but-
|
||||
;; not-yet-active profiles fall through to the duplicate-detection branch in
|
||||
;; `register-profile`, which never creates a session.
|
||||
(when (and (some? profile)
|
||||
(true? (:is-active profile)))
|
||||
(ex/raise :type :validation
|
||||
:code :email-already-exists
|
||||
:hint "email already exists"))
|
||||
|
||||
(let [props (-> (audit/extract-utm-params params)
|
||||
(cond-> (:accept-newsletter-updates params)
|
||||
(assoc :newsletter-updates true)))
|
||||
;; SECURITY: do NOT embed `:profile-id` of an existing
|
||||
;; profile into the prepared-register JWE. Doing so would
|
||||
;; let an anonymous caller, in possession of a valid
|
||||
;; team-invitation JWE, ask `register-profile` to load that
|
||||
;; profile by id and mint a session for it without password
|
||||
;; verification. `register-profile` independently re-detects
|
||||
;; duplicates by email and handles them in the
|
||||
;; "repeated-registry" branch.
|
||||
params {:email email
|
||||
:fullname fullname
|
||||
:password (:password params)
|
||||
:invitation-token (:invitation-token params)
|
||||
:backend "penpot"
|
||||
:iss :prepared-register
|
||||
:exp (ct/in-future {:days 7})
|
||||
:props props}
|
||||
params (d/without-nils params)
|
||||
token (tokens/generate cfg params)]
|
||||
|
||||
(-> {:token token}
|
||||
(with-meta {::audit/profile-id uuid/zero})))))
|
||||
|
||||
(def schema:prepare-register-profile
|
||||
[:map {:title "prepare-register-profile"}
|
||||
@ -389,25 +409,32 @@
|
||||
(profile/decode-row))))
|
||||
|
||||
(defn send-email-verification!
|
||||
[{:keys [::db/conn] :as cfg} profile]
|
||||
(let [vtoken (tokens/generate cfg
|
||||
{:iss :verify-email
|
||||
:exp (ct/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)})
|
||||
;; NOTE: this token is mainly used for possible complains
|
||||
;; identification on the sns webhook
|
||||
ptoken (tokens/generate cfg
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 30})})]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/register
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:token vtoken
|
||||
:extra-data ptoken})))
|
||||
([cfg profile] (send-email-verification! cfg profile nil))
|
||||
([{:keys [::db/conn] :as cfg} profile invitation-token]
|
||||
(let [vclaims (cond-> {:iss :verify-email
|
||||
:exp (ct/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)}
|
||||
;; If the user registered through a team-invitation flow but
|
||||
;; their profile is not yet active, we carry the invitation
|
||||
;; token inside the verify-email JWE so the team-invitation
|
||||
;; flow can resume after the user clicks the email link.
|
||||
(some? invitation-token)
|
||||
(assoc :invitation-token invitation-token))
|
||||
vtoken (tokens/generate cfg vclaims)
|
||||
;; NOTE: this token is mainly used for possible complains
|
||||
;; identification on the sns webhook
|
||||
ptoken (tokens/generate cfg
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)
|
||||
:exp (ct/in-future {:days 30})})]
|
||||
(eml/send! {::eml/conn conn
|
||||
::eml/factory eml/register
|
||||
:public-uri (cf/get :public-uri)
|
||||
:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:token vtoken
|
||||
:extra-data ptoken}))))
|
||||
|
||||
(defn register-profile
|
||||
[{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
|
||||
@ -416,23 +443,16 @@
|
||||
(:accept-newsletter-updates params)
|
||||
(update :props assoc :newsletter-updates true))
|
||||
|
||||
profile (if-let [profile-id (:profile-id claims)]
|
||||
(profile/get-profile conn profile-id)
|
||||
;; NOTE: we first try to match existing profile
|
||||
;; by email, that in normal circumstances will
|
||||
;; not return anything, but when a user tries to
|
||||
;; reuse the same token multiple times, we need
|
||||
;; to detect if the profile is already registered
|
||||
(or (profile/get-profile-by-email conn (:email claims))
|
||||
(let [is-active (or (boolean (:is-active claims))
|
||||
(boolean (:email-verified claims))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels cfg))]
|
||||
(vary-meta profile assoc :created true))))
|
||||
profile (or (profile/get-profile-by-email conn (:email claims))
|
||||
(let [is-active (or (boolean (:is-active claims))
|
||||
(boolean (:email-verified claims))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels cfg))]
|
||||
(vary-meta profile assoc :created true)))
|
||||
|
||||
created? (-> profile meta :created true?)
|
||||
|
||||
@ -463,48 +483,67 @@
|
||||
::audit/profile-id (:id profile)
|
||||
::audit/name "register-profile-retry"}))
|
||||
|
||||
;; If invitation token comes in params, this is because the user
|
||||
;; comes from team-invitation process; in this case, regenerate
|
||||
;; token and send back to the user a new invitation token (and
|
||||
;; mark current session as logged). This happens only if the
|
||||
;; invitation email matches with the register email.
|
||||
(and (some? invitation)
|
||||
(= (:email profile)
|
||||
(:member-email invitation)))
|
||||
(let [invitation (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg invitation)]
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)
|
||||
:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-meta {::audit/replace-props props
|
||||
::audit/context {:action "accept-invitation"}
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
;; When a new user is created and it is already activated by
|
||||
;; configuration or specified by OIDC, we just mark the profile
|
||||
;; as logged-in
|
||||
;; A profile was just created in this call. Invitation handling is a
|
||||
;; sub-case of "newly created profile": we never honor invitations for
|
||||
;; pre-existing profiles via this anonymous RPC. The split below mirrors
|
||||
;; the non-invitation branches but threads the invitation through the
|
||||
;; appropriate path:
|
||||
;;
|
||||
;; - active + matching invitation → mint session and
|
||||
;; return :invitation-token. The frontend redirects to
|
||||
;; :auth-verify-token, which immediately accepts the
|
||||
;; invitation.
|
||||
;; - active + no/mismatched invitation → mint session
|
||||
;; ("login" action). New profile, no further action.
|
||||
;; - not-active + matching invitation → send the
|
||||
;; verify-email mail with the invitation token EMBEDDED
|
||||
;; into the verify-email JWE. No session yet. When the
|
||||
;; user clicks the link, verify-token activates the
|
||||
;; profile, mints a session, and propagates the
|
||||
;; invitation token to the frontend so it can complete
|
||||
;; the team-invitation flow.
|
||||
;; - not-active + no/mismatched invitation → standard
|
||||
;; "check your email" verification flow.
|
||||
created?
|
||||
(if (:is-active profile)
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "login"}
|
||||
::audit/profile-id (:id profile)}))
|
||||
(let [accept-invitation? (and (some? invitation)
|
||||
(= (:email profile)
|
||||
(:member-email invitation)))]
|
||||
(cond
|
||||
(and (:is-active profile) accept-invitation?)
|
||||
(let [invitation (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg invitation)]
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)
|
||||
:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta {::audit/replace-props props
|
||||
::audit/context {:action "accept-invitation"}
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
(do
|
||||
(when-not (eml/has-reports? conn (:email profile))
|
||||
(send-email-verification! cfg profile))
|
||||
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)}
|
||||
(:is-active profile)
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "email-verification"}
|
||||
::audit/profile-id (:id profile)}))))
|
||||
::audit/context {:action "login"}
|
||||
::audit/profile-id (:id profile)}))
|
||||
|
||||
:else
|
||||
(do
|
||||
(when-not (eml/has-reports? conn (:email profile))
|
||||
(send-email-verification! cfg profile
|
||||
(when accept-invitation?
|
||||
(:invitation-token params))))
|
||||
|
||||
(-> {:id (:id profile)
|
||||
:email (:email profile)}
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
::audit/context {:action "email-verification"}
|
||||
::audit/profile-id (:id profile)})))))
|
||||
|
||||
:else
|
||||
(let [elapsed? (elapsed-verify-threshold? profile)
|
||||
|
||||
@ -74,6 +74,11 @@
|
||||
{:is-active true}
|
||||
{:id (:id profile)}))
|
||||
|
||||
;; NOTE: `claims` is returned verbatim (besides :profile). When the
|
||||
;; verify-email JWE was minted by `register-profile` for a not-yet-
|
||||
;; active profile that came from an invitation flow, `:invitation-
|
||||
;; token` will be present here and the frontend will use it to
|
||||
;; complete the team-invitation flow after login.
|
||||
(-> claims
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
(rph/with-meta {::audit/name "verify-profile-email"
|
||||
|
||||
@ -527,32 +527,89 @@
|
||||
(t/is (= 0 (:call-count @mock))))))))
|
||||
|
||||
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1
|
||||
(let [itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "user@example.com"})
|
||||
data {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
;; With email-verification ENABLED (the default), a brand-new
|
||||
;; profile created via the invitation flow is NOT active yet, so
|
||||
;; `register-profile` must NOT mint a session and must NOT echo
|
||||
;; back the invitation token. Instead it must dispatch the
|
||||
;; verify-email mail with the invitation token EMBEDDED into the
|
||||
;; verify-email JWE (so the team-invitation flow can resume after
|
||||
;; the user clicks the email link).
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "user@example.com"})
|
||||
prep-data {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
|
||||
{:keys [result error] :as out} (th/command! data)]
|
||||
(t/is (nil? error))
|
||||
(t/is (map? result))
|
||||
(t/is (string? (:token result)))
|
||||
{prep-result :result prep-error :error} (th/command! prep-data)]
|
||||
(t/is (nil? prep-error))
|
||||
(t/is (map? prep-result))
|
||||
(t/is (string? (:token prep-result)))
|
||||
|
||||
(let [rtoken (:token result)
|
||||
data {::th/type :register-profile
|
||||
:token rtoken}
|
||||
(let [reg-data {::th/type :register-profile
|
||||
:token (:token prep-result)}
|
||||
|
||||
{:keys [result error] :as out} (th/command! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? error))
|
||||
(t/is (map? result))
|
||||
(t/is (string? (:invitation-token result))))))
|
||||
{reg-result :result reg-error :error} (th/command! reg-data)
|
||||
mdata (meta reg-result)]
|
||||
(t/is (nil? reg-error))
|
||||
(t/is (map? reg-result))
|
||||
|
||||
;; No invitation token echoed back, no session minted.
|
||||
(t/is (nil? (:invitation-token reg-result)))
|
||||
(t/is (empty? (:app.rpc/response-transform-fns mdata)))
|
||||
|
||||
;; The verify-email mail was dispatched, and its token claims
|
||||
;; carry the invitation-token through to the verification step.
|
||||
(t/is (= 1 (:call-count @mock)))
|
||||
(let [send-args (-> @mock :call-args)
|
||||
email-token (->> send-args (some (fn [m] (when (map? m) (:token m)))))
|
||||
vclaims (tokens/decode th/*system* email-token)]
|
||||
(t/is (= :verify-email (:iss vclaims)))
|
||||
(t/is (= itoken (:invitation-token vclaims))))))))
|
||||
|
||||
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-1b
|
||||
;; With email-verification DISABLED, the brand-new profile is
|
||||
;; immediately active, so `register-profile` mints a session and
|
||||
;; returns the regenerated invitation token in the body — the
|
||||
;; frontend then redirects to :auth-verify-token to complete the
|
||||
;; team-invitation flow.
|
||||
(with-redefs [app.config/flags #{:registration :login-with-password}]
|
||||
(let [itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "user@example.com"})
|
||||
prep-data {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "foobar"
|
||||
:email "user@example.com"
|
||||
:password "foobar"}
|
||||
|
||||
{prep-result :result prep-error :error} (th/command! prep-data)]
|
||||
(t/is (nil? prep-error))
|
||||
(t/is (string? (:token prep-result)))
|
||||
|
||||
(let [reg-data {::th/type :register-profile
|
||||
:token (:token prep-result)}
|
||||
|
||||
{reg-result :result reg-error :error} (th/command! reg-data)
|
||||
mdata (meta reg-result)]
|
||||
(t/is (nil? reg-error))
|
||||
(t/is (map? reg-result))
|
||||
|
||||
;; Active branch: invitation-token is echoed back and a session
|
||||
;; is minted via `session/create-fn`.
|
||||
(t/is (string? (:invitation-token reg-result)))
|
||||
(t/is (seq (:app.rpc/response-transform-fns mdata)))
|
||||
(t/is (= "accept-invitation"
|
||||
(get-in mdata [:app.loggers.audit/context :action])))))))
|
||||
|
||||
(t/deftest prepare-and-register-with-invitation-and-enabled-registration-2
|
||||
(let [itoken (tokens/generate th/*system*
|
||||
@ -705,6 +762,188 @@
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :email-as-password (:code edata))))))
|
||||
|
||||
(t/deftest prepare-register-rejects-active-profile-email
|
||||
;; SECURITY: `prepare-register` must reject any attempt to prepare a
|
||||
;; registration for an email that already belongs to an *active*
|
||||
;; profile, regardless of whether an invitation token is supplied.
|
||||
;; Active profiles must use the standard login flow.
|
||||
(let [_victim (th/create-profile* 1 {:is-active true
|
||||
:email "victim@corp.tld"})]
|
||||
|
||||
;; Without invitation token.
|
||||
(let [out (th/command! {::th/type :prepare-register-profile
|
||||
:fullname "Mallory"
|
||||
:email "victim@corp.tld"
|
||||
:password "Whatever1!"})]
|
||||
(t/is (not (th/success? out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :email-already-exists (:code edata)))))
|
||||
|
||||
;; With invitation token (the GHSA-4937-35vc-hqjj exploit shape).
|
||||
(let [itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "victim@corp.tld"})
|
||||
out (th/command! {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "Mallory"
|
||||
:email "victim@corp.tld"
|
||||
:password "Whatever1!"})]
|
||||
(t/is (not (th/success? out)))
|
||||
(let [edata (-> out :error ex-data)]
|
||||
(t/is (= :validation (:type edata)))
|
||||
(t/is (= :email-already-exists (:code edata)))))))
|
||||
|
||||
(t/deftest prepare-register-must-not-leak-existing-profile-id
|
||||
;; Victim is a pre-existing profile that has not yet activated (e.g.
|
||||
;; freshly registered, has not clicked the email verification link).
|
||||
;; `prepare-register` allows the call (no active profile exists), but
|
||||
;; the issued JWE must NOT carry the existing profile's id.
|
||||
(let [_victim (th/create-profile* 1 {:is-active false
|
||||
:email "victim@corp.tld"})
|
||||
|
||||
;; Attacker holds a cryptographically valid `:team-invitation` JWE
|
||||
;; for the victim's email. (In a real exploit this is obtained
|
||||
;; from `create-team-invitations` or `get-team-invitation-token`
|
||||
;; on a team the attacker owns.)
|
||||
itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "victim@corp.tld"})
|
||||
|
||||
;; Anonymous request — no ::rpc/profile-id.
|
||||
data {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "Mallory"
|
||||
:email "victim@corp.tld"
|
||||
:password "Whatever1!"}
|
||||
|
||||
out (th/command! data)]
|
||||
|
||||
;; The current behaviour either returns a token or rejects the request;
|
||||
;; what MUST hold is that the issued prepared-register JWE does not
|
||||
;; carry the victim's profile id.
|
||||
(t/is (th/success? out))
|
||||
|
||||
(let [token (-> out :result :token)
|
||||
claims (tokens/decode th/*system* token)]
|
||||
(t/is (= :prepared-register (:iss claims)))
|
||||
;; This is the root-cause assertion: an anonymous prepare-register
|
||||
;; call must NEVER embed an existing profile's id.
|
||||
(t/is (nil? (:profile-id claims))
|
||||
"prepare-register must not embed existing profile id of an anonymous caller"))))
|
||||
|
||||
(t/deftest register-profile-with-invitation-must-not-take-over-existing-account
|
||||
(with-mocks [_mock {:target 'app.email/send! :return nil}]
|
||||
(let [;; Victim profile exists but is not yet active (e.g. registered
|
||||
;; but has not clicked the verification link). This is the
|
||||
;; remaining attack surface after fix 1b: `prepare-register`
|
||||
;; will not reject this case, so the `register-profile` path
|
||||
;; must enforce the security invariants on its own.
|
||||
victim (th/create-profile* 1 {:is-active false
|
||||
:email "victim@corp.tld"})
|
||||
|
||||
;; Attacker mints a valid `:team-invitation` JWE for the victim's
|
||||
;; email. No member-id is included (matches what an attacker
|
||||
;; obtains via `create-team-invitations` against their own team
|
||||
;; before the victim has joined).
|
||||
itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email "victim@corp.tld"})
|
||||
|
||||
;; Step 1 (anonymous): prepare-register-profile with the victim's
|
||||
;; email + the invitation token.
|
||||
prep-out (th/command! {::th/type :prepare-register-profile
|
||||
:invitation-token itoken
|
||||
:fullname "Mallory"
|
||||
:email "victim@corp.tld"
|
||||
:password "Whatever1!"})
|
||||
|
||||
rtoken (-> prep-out :result :token)
|
||||
|
||||
;; Step 2 (anonymous): register-profile with the prepared token.
|
||||
reg-out (th/command! {::th/type :register-profile
|
||||
:token rtoken})
|
||||
|
||||
result (:result reg-out)
|
||||
mdata (meta result)]
|
||||
|
||||
;; The first call may succeed; the issue is what the second call
|
||||
;; produces. We assert the security invariants on its result.
|
||||
(t/is (th/success? prep-out))
|
||||
|
||||
;; INVARIANT 1: register-profile must NOT install a session for the
|
||||
;; victim. `session/create-fn` is wired via
|
||||
;; `rph/with-transform`, which appends to
|
||||
;; `:app.rpc/response-transform-fns`. If that vector is non-empty
|
||||
;; for an anonymous register that targets an EXISTING profile, the
|
||||
;; server is about to mint an `auth-token` cookie bound to the
|
||||
;; victim — i.e. account takeover.
|
||||
(t/is (empty? (:app.rpc/response-transform-fns mdata))
|
||||
"register-profile must not create a session for an existing victim profile")
|
||||
|
||||
;; INVARIANT 2: register-profile must NOT echo back an invitation
|
||||
;; token that authenticates as the victim. When the response
|
||||
;; contains both `:id` matching the victim and `:invitation-token`,
|
||||
;; the frontend treats the user as logged-in for that profile.
|
||||
(when (and (map? result)
|
||||
(= (:id victim) (:id result)))
|
||||
(t/is (not (contains? result :invitation-token))
|
||||
"register-profile must not return an invitation-token bound to an existing victim profile"))
|
||||
|
||||
;; INVARIANT 3: the server must NOT have taken the
|
||||
;; "accept-invitation" branch (which is the one that mints a
|
||||
;; session). For an existing victim profile, the operation
|
||||
;; should fall through to the harmless "repeated registry" path.
|
||||
(t/is (not= "accept-invitation"
|
||||
(get-in mdata [:app.loggers.audit/context :action]))
|
||||
"register-profile must not run the accept-invitation branch for an existing victim profile")
|
||||
;; The victim must remain inactive: nothing in this anonymous
|
||||
;; flow should have flipped `is-active` to true.
|
||||
(let [reloaded (th/db-get :profile {:id (:id victim)})]
|
||||
(t/is (false? (:is-active reloaded))
|
||||
"register-profile must not activate the victim profile")))))
|
||||
|
||||
(t/deftest verify-email-with-invitation-token-propagates-it
|
||||
;; A `:verify-email` JWE that carries `:invitation-token` (as
|
||||
;; produced by `register-profile` for the not-active+invitation
|
||||
;; case) must propagate that token through the verify-token RPC
|
||||
;; result so the frontend can resume the team-invitation flow.
|
||||
(let [profile (th/create-profile* 1 {:is-active false})
|
||||
itoken (tokens/generate th/*system*
|
||||
{:iss :team-invitation
|
||||
:exp (ct/in-future "48h")
|
||||
:role :editor
|
||||
:team-id uuid/zero
|
||||
:member-email (:email profile)})
|
||||
vtoken (tokens/generate th/*system*
|
||||
{:iss :verify-email
|
||||
:exp (ct/in-future "72h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)
|
||||
:invitation-token itoken})
|
||||
|
||||
out (th/command! {::th/type :verify-token
|
||||
:token vtoken})
|
||||
result (:result out)]
|
||||
|
||||
(t/is (th/success? out))
|
||||
(t/is (= :verify-email (:iss result)))
|
||||
(t/is (= itoken (:invitation-token result))
|
||||
"verify-token must echo back the invitation-token from the verify-email JWE")
|
||||
|
||||
;; And the profile must now be active.
|
||||
(let [reloaded (th/db-get :profile {:id (:id profile)})]
|
||||
(t/is (true? (:is-active reloaded))))))
|
||||
|
||||
(t/deftest email-change-request
|
||||
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||
(let [profile (th/create-profile* 1)
|
||||
|
||||
BIN
docs/img/troubleshooting/troubleshooting-chrome.webp
Normal file
BIN
docs/img/troubleshooting/troubleshooting-chrome.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/img/troubleshooting/troubleshooting-firefox.webp
Normal file
BIN
docs/img/troubleshooting/troubleshooting-firefox.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
107
docs/user-guide/first-steps/troubleshooting-webgl.njk
Normal file
107
docs/user-guide/first-steps/troubleshooting-webgl.njk
Normal file
@ -0,0 +1,107 @@
|
||||
---
|
||||
title: Troubleshooting WebGL
|
||||
order: 5
|
||||
desc: Diagnose and fix common WebGL issues in Penpot with browser, GPU, and system checks so you can open the workspace canvas correctly.
|
||||
---
|
||||
|
||||
<h1 id="troubleshooting-webgl">Troubleshooting WebGL</h1>
|
||||
|
||||
<div class="advice">
|
||||
<h3>Availability note</h3>
|
||||
<p>WebGL renderer is currently not available yet in Penpot production (<code>design.penpot.app</code>).</p>
|
||||
<p>Right now, this renderer is available only in <strong>testing environments</strong>. It is planned for an upcoming release and should be available soon.</p>
|
||||
</div>
|
||||
|
||||
<p class="main-paragraph">Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.</p>
|
||||
|
||||
<p>Sometimes WebGL appears enabled in your browser, but Penpot still cannot create a graphics context. This is usually related to browser settings, GPU acceleration, drivers, or temporary GPU overload.</p>
|
||||
|
||||
<h2>Before changing anything</h2>
|
||||
<ol>
|
||||
<li>Open <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
|
||||
<li>Check the result:
|
||||
<ul>
|
||||
<li>If you see a spinning cube, WebGL works at browser level.</li>
|
||||
<li>If it fails (blank page, error message, or no animation), continue with browser and system checks below.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h2>Quick checks (2 minutes)</h2>
|
||||
<ol>
|
||||
<li>Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).</li>
|
||||
<li>Reload Penpot.</li>
|
||||
<li>Fully restart the browser.</li>
|
||||
<li>If needed, restart your computer.</li>
|
||||
</ol>
|
||||
<p>Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.</p>
|
||||
|
||||
<h2>Chrome</h2>
|
||||
<ol>
|
||||
<li>Open <code>chrome://settings/system</code>.</li>
|
||||
<li>Turn on <strong>Use graphics acceleration when available</strong>.</li>
|
||||
<li>Restart Chrome.</li>
|
||||
<li>Open <code>chrome://gpu</code> and review WebGL-related warnings.</li>
|
||||
</ol>
|
||||
<figure>
|
||||
<a href="/img/troubleshooting/troubleshooting-chrome.webp" target="_blank">
|
||||
<img src="/img/troubleshooting/troubleshooting-chrome.webp" alt="Chrome system settings with graphics acceleration option" />
|
||||
</a>
|
||||
</figure>
|
||||
<p>Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.</p>
|
||||
|
||||
<h2>Mozilla Firefox</h2>
|
||||
<ol>
|
||||
<li>Open Firefox and check that zoom is set to <strong>100%</strong> from the top-right menu.</li>
|
||||
<li>From that same menu, open Settings/Preferences.</li>
|
||||
<li>In General settings, confirm Firefox is up to date and run <strong>Check for updates</strong> if needed.</li>
|
||||
<li>Enable hardware acceleration in Firefox settings.</li>
|
||||
<li>Restart Firefox.</li>
|
||||
<li>Open <code>about:support</code> and review the Graphics/WebGL section.</li>
|
||||
</ol>
|
||||
<figure>
|
||||
<a href="/img/troubleshooting/troubleshooting-firefox.webp" target="_blank">
|
||||
<img src="/img/troubleshooting/troubleshooting-firefox.webp" alt="Firefox settings showing hardware acceleration configuration" />
|
||||
</a>
|
||||
</figure>
|
||||
<p>Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.</p>
|
||||
|
||||
<h2>Safari</h2>
|
||||
<ol>
|
||||
<li>Update Safari/macOS to the latest available version.</li>
|
||||
<li>Restart Safari.</li>
|
||||
<li>Re-test in <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
|
||||
</ol>
|
||||
<p>Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.</p>
|
||||
|
||||
<h2>Settings</h2>
|
||||
<p>Some advanced browser configurations or experimental settings can interfere with WebGL. If you have modified these in the past, consider restoring default browser settings or testing in a fresh profile.</p>
|
||||
|
||||
<h2>About zoom and trackpad settings</h2>
|
||||
<p>In some cases, changing browser zoom or trackpad settings is suggested as a workaround.</p>
|
||||
<p>In Penpot, these are <strong>not baseline requirements</strong> for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.</p>
|
||||
<p>If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.</p>
|
||||
|
||||
<h2>GPU drivers and OS checks</h2>
|
||||
<ol>
|
||||
<li>Install any pending OS updates.</li>
|
||||
<li>Update GPU drivers (especially on Windows/Linux).</li>
|
||||
<li>Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.</li>
|
||||
</ol>
|
||||
<p>Why this helps: outdated or conflicting graphics layers can break WebGL context creation.</p>
|
||||
|
||||
<h2>Known edge case: Linux + Nvidia</h2>
|
||||
<p>Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.</p>
|
||||
<p>In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.</p>
|
||||
<p>If this is your setup, collect diagnostics and contact support.</p>
|
||||
|
||||
<h2>If the issue persists</h2>
|
||||
<p>Please share:</p>
|
||||
<ul>
|
||||
<li>Browser and version.</li>
|
||||
<li>Operating system and version.</li>
|
||||
<li>Result from <a href="https://get.webgl.org/" target="_blank">https://get.webgl.org</a>.</li>
|
||||
<li>A screenshot of browser graphics diagnostics (<code>chrome://gpu</code> or <code>about:support</code>).</li>
|
||||
</ul>
|
||||
|
||||
<p>Then contact us at <a href="mailto:support@penpot.app">support@penpot.app</a> or open a GitHub issue at <a href="https://github.com/penpot/penpot/issues" target="_blank">https://github.com/penpot/penpot/issues</a>.</p>
|
||||
@ -663,14 +663,11 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
;; We do not allow to apply tokens while text editor is open.
|
||||
;; The classic text editor sets :workspace-editor-state; the WASM text editor
|
||||
;; does not, so we also check :workspace-local :edition for text shapes.
|
||||
(let [edition (get-in state [:workspace-local :edition])
|
||||
objects (dsh/lookup-page-objects state)
|
||||
text-editing? (and (some? edition)
|
||||
(= :text (:type (get objects edition))))]
|
||||
(if (and (empty? (get state :workspace-editor-state))
|
||||
(some? token)
|
||||
(if (and (some? token)
|
||||
(not text-editing?))
|
||||
(let [attributes-to-remove
|
||||
;; Remove atomic typography tokens when applying composite and vice-versa
|
||||
|
||||
@ -81,6 +81,7 @@
|
||||
on-error
|
||||
(mf/use-fn
|
||||
(fn [cause]
|
||||
(reset! submitted? false)
|
||||
(let [{:keys [type code] :as edata} (ex-data cause)]
|
||||
(condp = [type code]
|
||||
[:restriction :email-does-not-match-invitation]
|
||||
@ -98,6 +99,9 @@
|
||||
[:restriction :email-has-complaints]
|
||||
(st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
|
||||
|
||||
[:validation :email-already-exists]
|
||||
(st/emit! (ntf/error (tr "errors.email-already-exists")))
|
||||
|
||||
[:validation :email-as-password]
|
||||
(swap! form assoc-in [:errors :password]
|
||||
{:message (tr "errors.email-as-password")})
|
||||
|
||||
@ -25,11 +25,19 @@
|
||||
(defmulti handle-token (fn [token] (:iss token)))
|
||||
|
||||
(defmethod handle-token :verify-email
|
||||
[data]
|
||||
[{:keys [invitation-token] :as data}]
|
||||
(cf/external-notify-register-success (:profile-id data))
|
||||
(let [msg (tr "dashboard.notifications.email-verified-successfully")]
|
||||
(ts/schedule 1000 #(st/emit! (ntf/success msg)))
|
||||
(st/emit! (da/login-from-token data))))
|
||||
;; If the verify-email JWE carries an :invitation-token, it means
|
||||
;; the user registered via a team-invitation flow but had to verify
|
||||
;; their email first. Log them in and then redirect to
|
||||
;; :auth-verify-token with the invitation token, which will accept
|
||||
;; the invitation as a logged-in user.
|
||||
(if invitation-token
|
||||
(st/emit! (da/login-from-token data)
|
||||
(rt/nav :auth-verify-token {:token invitation-token}))
|
||||
(st/emit! (da/login-from-token data)))))
|
||||
|
||||
(defmethod handle-token :change-email
|
||||
[_data]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user