From 66337f2ab919e961ee1b06f2b778cf58be02f305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?andr=C3=A9s=20gonz=C3=A1lez?= Date: Mon, 4 May 2026 09:34:19 +0200 Subject: [PATCH 1/6] :books: Add WebGL Troubleshooting Guide --- .../troubleshooting-chrome.webp | Bin 0 -> 17308 bytes .../troubleshooting-firefox.webp | Bin 0 -> 14950 bytes .../first-steps/troubleshooting-webgl.njk | 107 ++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 docs/img/troubleshooting/troubleshooting-chrome.webp create mode 100644 docs/img/troubleshooting/troubleshooting-firefox.webp create mode 100644 docs/user-guide/first-steps/troubleshooting-webgl.njk diff --git a/docs/img/troubleshooting/troubleshooting-chrome.webp b/docs/img/troubleshooting/troubleshooting-chrome.webp new file mode 100644 index 0000000000000000000000000000000000000000..a999f41b2db73ac69905771626e99b1acd3ac449 GIT binary patch literal 17308 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 E0&DeuF8}}l literal 0 HcmV?d00001 diff --git a/docs/img/troubleshooting/troubleshooting-firefox.webp b/docs/img/troubleshooting/troubleshooting-firefox.webp new file mode 100644 index 0000000000000000000000000000000000000000..93c151fee5fbd81666471678cdebaae040a4f074 GIT binary patch literal 14950 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 aboSQpuLbZ2-BRZ1^S}Sva>&pj0RICVxKFYG literal 0 HcmV?d00001 diff --git a/docs/user-guide/first-steps/troubleshooting-webgl.njk b/docs/user-guide/first-steps/troubleshooting-webgl.njk new file mode 100644 index 0000000000..bf241d0b1f --- /dev/null +++ b/docs/user-guide/first-steps/troubleshooting-webgl.njk @@ -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. +--- + +

Troubleshooting WebGL

+ +
+

Availability note

+

WebGL renderer is currently not available yet in Penpot production (design.penpot.app).

+

Right now, this renderer is available only in testing environments. It is planned for an upcoming release and should be available soon.

+
+ +

Penpot uses WebGL to render the design canvas. If WebGL is unavailable, Penpot cannot open the workspace canvas correctly.

+ +

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.

+ +

Before changing anything

+
    +
  1. Open https://get.webgl.org.
  2. +
  3. Check the result: +
      +
    • If you see a spinning cube, WebGL works at browser level.
    • +
    • If it fails (blank page, error message, or no animation), continue with browser and system checks below.
    • +
    +
  4. +
+ +

Quick checks (2 minutes)

+
    +
  1. Close graphics-heavy tabs/apps (video editors, 3D apps, many design tabs).
  2. +
  3. Reload Penpot.
  4. +
  5. Fully restart the browser.
  6. +
  7. If needed, restart your computer.
  8. +
+

Why this helps: GPU memory or context slots can be temporarily exhausted, even when your configuration is correct.

+ +

Chrome

+
    +
  1. Open chrome://settings/system.
  2. +
  3. Turn on Use graphics acceleration when available.
  4. +
  5. Restart Chrome.
  6. +
  7. Open chrome://gpu and review WebGL-related warnings.
  8. +
+
+ + Chrome system settings with graphics acceleration option + +
+

Why this helps: WebGL depends on hardware acceleration and a healthy GPU process.

+ +

Mozilla Firefox

+
    +
  1. Open Firefox and check that zoom is set to 100% from the top-right menu.
  2. +
  3. From that same menu, open Settings/Preferences.
  4. +
  5. In General settings, confirm Firefox is up to date and run Check for updates if needed.
  6. +
  7. Enable hardware acceleration in Firefox settings.
  8. +
  9. Restart Firefox.
  10. +
  11. Open about:support and review the Graphics/WebGL section.
  12. +
+
+ + Firefox settings showing hardware acceleration configuration + +
+

Why this helps: outdated browser builds, disabled acceleration, or blocked GPU features can prevent context creation.

+ +

Safari

+
    +
  1. Update Safari/macOS to the latest available version.
  2. +
  3. Restart Safari.
  4. +
  5. Re-test in https://get.webgl.org.
  6. +
+

Why this helps: Safari WebGL behavior is strongly tied to OS/browser version and graphics stack updates.

+ +

Settings

+

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.

+ +

About zoom and trackpad settings

+

In some cases, changing browser zoom or trackpad settings is suggested as a workaround.

+

In Penpot, these are not baseline requirements for WebGL. Treat them only as temporary diagnostics if support explicitly asks for them.

+

If you temporarily changed one of these settings and Penpot starts working, you can usually revert it and test again.

+ +

GPU drivers and OS checks

+
    +
  1. Install any pending OS updates.
  2. +
  3. Update GPU drivers (especially on Windows/Linux).
  4. +
  5. Disable graphics overlays/tools (recording overlays, GPU tuning utilities) and test again.
  6. +
+

Why this helps: outdated or conflicting graphics layers can break WebGL context creation.

+ +

Known edge case: Linux + Nvidia

+

Some Linux + Nvidia combinations can report WebGL as available but still fail at runtime in specific browser/driver combinations.

+

In some cases, switching between proprietary and open-source drivers or updating the NVIDIA driver resolves the issue.

+

If this is your setup, collect diagnostics and contact support.

+ +

If the issue persists

+

Please share:

+
    +
  • Browser and version.
  • +
  • Operating system and version.
  • +
  • Result from https://get.webgl.org.
  • +
  • A screenshot of browser graphics diagnostics (chrome://gpu or about:support).
  • +
+ +

Then contact us at support@penpot.app or open a GitHub issue at https://github.com/penpot/penpot/issues.

\ No newline at end of file From ce24fed32b46dc8f0fe2ef9123fcf9d12efa0362 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Wed, 6 May 2026 08:47:30 +0200 Subject: [PATCH 2/6] :bug: Fix incorrect text-edition warning when applying tokens (#9355) --- CHANGES.md | 1 + frontend/src/app/main/data/workspace/tokens/application.cljs | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a7293b6411..f4b0796667 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,7 @@ ### :bug: Bugs fixed - 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.4 diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 10c148fe9f..097ea14e85 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -660,13 +660,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)) + (if (and (some? token) (not text-editing?)) (let [attributes-to-remove ;; Remove atomic typography tokens when applying composite and vice-verca From 708c4065b3208e276ffc5d1cdae875a5579ff1a7 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 6 May 2026 07:33:40 +0200 Subject: [PATCH 3/6] :bug: Fix drag and drop cache eligibility rules --- render-wasm/src/render.rs | 68 ++++++++++++++++-------- render-wasm/src/shapes.rs | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 22 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1b384e235d..a6822c991e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -391,6 +391,9 @@ pub(crate) struct RenderState { pub struct InteractiveDragCrop { pub src_doc_bounds: Rect, pub src_selrect: Rect, + /// True if the captured crop bounds were fully inside the viewport at capture time. + /// Used to avoid serving partial/offscreen crops during interactive drag. + pub fits_viewport_at_capture: bool, pub image: skia::Image, } @@ -444,13 +447,30 @@ impl RenderState { let Some(m) = tree.get_modifier(&node_id) else { return false; }; - return crate::math::is_move_only_matrix(m); + // Only allow using the cached pixels for pure translations. + // For non-translation transforms (scale/rotate/skew), cached pixels won't match. + if !crate::math::is_move_only_matrix(m) { + return false; + } + + let Some(crop) = self.backbuffer_crop_cache.get(&node_id) else { + return false; + }; + if !crop.fits_viewport_at_capture { + return false; + } + + // Additionally require this node to be safe to serve from a rectangular backbuffer + // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). + return tree + .get(&node_id) + .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); } - // Invalidate cached top-level pixels whenever the moving content overlaps - // the cached pixel area. Use `src_doc_bounds` because it's the exact bounds - // captured from the Backbuffer crop (more reliable than extents derived - // from layout/layout-less container heuristics). + // If the moving content overlaps this cached crop, do not use the cached pixels + // for this frame. We intentionally keep the cache entry: overlap is typically + // transient during drag, and once the moving content leaves the area the crop + // becomes valid again (stationary shape unchanged). if let Some(moved) = moved_bounds { let intersects = self .backbuffer_crop_cache @@ -458,25 +478,12 @@ impl RenderState { .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); if intersects { - // Simplest "automatic invalidation": once something moves over this cached - // area, drop the cached crop so it won't be reused again until the next - // full-frame rebuild. - self.backbuffer_crop_cache.remove(&node_id); return false; } } true } - fn is_recortable_for_drag_crop(&self, shape: &Shape) -> bool { - // "Recortable" (happy path): the shape is fully represented by the pixels - // already in Backbuffer and can be moved as a texture during drag. - shape.blur.is_none() - && shape.shadows.is_empty() - && (shape.opacity - 1.0).abs() <= 1e-4 - && shape.blend_mode().0 == skia::BlendMode::SrcOver - } - pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let mut gpu_state = GpuState::try_new()?; @@ -1649,9 +1656,6 @@ impl RenderState { if shape.hidden { continue; } - if !self.is_recortable_for_drag_crop(shape) { - continue; - } let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); if !doc_bounds.intersects(viewport) { @@ -1707,11 +1711,16 @@ impl RenderState { src_irect.right as f32 / scale + vb_left, src_irect.bottom as f32 / scale + vb_top, ); + let fits_viewport_at_capture = doc_bounds.left >= viewport.left + && doc_bounds.top >= viewport.top + && doc_bounds.right <= viewport.right + && doc_bounds.bottom <= viewport.bottom; self.backbuffer_crop_cache.insert( id, InteractiveDragCrop { src_doc_bounds, src_selrect: selrect, + fits_viewport_at_capture, image, }, ); @@ -3024,16 +3033,31 @@ impl RenderState { dst_doc_rect.height() * scale, ); - // let canvas = self.surfaces.canvas_and_mark_dirty(target_surface); let canvas = self.surfaces.canvas(target_surface); canvas.save(); canvas.reset_matrix(); + // If the crop includes shadows/blur (extrect pixels outside the fill/stroke + // silhouette), do NOT apply the silhouette clip or we'd cut those pixels. + let should_clip_crop = element.shadows.is_empty() && element.blur.is_none(); + if should_clip_crop { + if let Some(clip_path) = element.drag_crop_clip_path() { + let mut doc_to_tile = Matrix::new_identity(); + // Map document-space coordinates into tile pixels. + // Rendering surfaces apply: scale(scale) then translate(translation) in doc units. + // Equivalent point mapping: (doc + translation) * scale. + doc_to_tile.post_translate((translation.0, translation.1)); + doc_to_tile.post_scale((scale, scale), None); + let clip_path = clip_path.make_transform(&doc_to_tile); + canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); + } + } canvas.draw_image_rect( crop_image, None, dst_tile_rect, &skia::Paint::default(), ); + canvas.restore(); } continue; diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index ec99e7fef0..22e9e1e9d8 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1358,6 +1358,112 @@ impl Shape { } } + /// Same `concat` applied around [`center`](Self::center) as in `render_shape` (non-text branch). + fn shape_document_transform(&self) -> Matrix { + let c = self.center(); + let mut m = self.transform; + m.post_translate(c); + m.pre_translate(-c); + m + } + + /// Fill silhouette only, document space (matches fill rendering). + fn drag_crop_fill_clip_path_skia(&self) -> Option { + match &self.shape_type { + Type::Rect(r) => { + let p = Path::new(shape_to_path::rect_segments(self, r.corners)); + Some(p.to_skia_path(self.svg_attrs.as_ref())) + } + Type::Circle => { + let p = Path::new(shape_to_path::circle_segments(self)); + Some(p.to_skia_path(self.svg_attrs.as_ref())) + } + Type::Path(_) | Type::Bool(_) => { + let sk = self.get_skia_path()?; + Some(sk.make_transform(&self.shape_document_transform())) + } + _ => None, + } + } + + /// Whether this shape may use the backbuffer crop fast path during interactive drag. + /// + /// Conservative: only effects and fills that match what we snapshot and clip in + /// [`drag_crop_clip_path`](Self::drag_crop_clip_path). Text is never safe (glyph layout, + /// no `drag_crop_clip_path`). + pub fn is_safe_for_drag_crop_cache(&self, shapes_pool: ShapesPoolRef) -> bool { + if matches!(self.shape_type, Type::Text(_)) { + return false; + } + + // If a frame shows overflow (clip_content=false) and its visible content exceeds the + // frame bounds, a cached crop anchored to the frame can easily become incorrect while + // moving (children can extend beyond selrect). Be conservative and render live. + if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content { + let extrect = self.extrect(shapes_pool, 1.0); + let sr = self.selrect; + let exceeds = extrect.left < sr.left + || extrect.top < sr.top + || extrect.right > sr.right + || extrect.bottom > sr.bottom; + if exceeds { + return false; + } + } + + let has_opaque_fill = self + .fills + .iter() + .any(|f| math::is_close_to(f.opacity(), 1.0)); + + self.blur.is_none() + && self.shadows.is_empty() + && (self.opacity - 1.0).abs() <= 1e-4 + && self.blend_mode().0 == skia::BlendMode::SrcOver + && has_opaque_fill + } + + /// Fill + visible strokes in **document space** for clipping interactive drag textures. + /// + /// The backbuffer crop uses an axis-aligned `extrect`; we clip the blit so backdrop pixels + /// outside the real silhouette (fill and stroke regions) are not smeared. Strokes use + /// [`stroke_to_path`](stroke_to_path) like the main renderer, then union with the fill path. + pub fn drag_crop_clip_path(&self) -> Option { + let mut acc = self.drag_crop_fill_clip_path_skia()?; + if !self.has_visible_strokes() { + return Some(acc); + } + + let shape_path = match &self.shape_type { + Type::Rect(r) => Path::new(shape_to_path::rect_segments(self, r.corners)), + Type::Circle => Path::new(shape_to_path::circle_segments(self)), + Type::Path(_) | Type::Bool(_) => self.shape_type.path()?.clone(), + _ => return Some(acc), + }; + + let path_transform = self.to_path_transform(); + let apply_doc_transform = path_transform.is_some(); + + for stroke in self.visible_strokes() { + let Some(stroke_region) = stroke_to_path( + stroke, + &shape_path, + path_transform.as_ref(), + &self.selrect, + self.svg_attrs.as_ref(), + ) else { + continue; + }; + let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); + if apply_doc_transform { + sk = sk.make_transform(&self.shape_document_transform()); + } + acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); + } + + Some(acc) + } + fn transform_selrect(&mut self, transform: &Matrix) { if math::is_move_only_matrix(transform) { let tx = transform.translate_x(); From 9e681260ccc4feb6c564ff0773fb9594b462c574 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 6 May 2026 14:20:55 +0200 Subject: [PATCH 4/6] :bug: Fix incorrect invitation token handling on register process (#9380) * :bug: Fix incorrect invitation token handling on register process - Reject prepare-register-profile when an active profile already exists for the requested email. - Stop embedding an existing profile's :profile-id into the prepared-register JWE. Profile resolution in register-profile is now done exclusively by email lookup, never by a JWE claim. - Add created? guard to the invitation-success branch in register-profile, so existing profiles (active or not) cannot reach session creation via anonymous registration. Signed-off-by: Andrey Antukh * :recycle: Restructure invitation handling inside register-profile Move the invitation-success branch into the created? sub-cond so it sits alongside the other post-creation branches, making the control flow consistent. - Active new profile + matching invitation: mint session and return :invitation-token (frontend redirects to :auth-verify-token). - Not-yet-active new profile + matching invitation: embed the invitation token inside the verify-email JWE and send the verification email. When the user clicks the link, they get logged in and the frontend completes the team-invitation flow. - Extend send-email-verification! with an optional invitation-token parameter propagated into the verify-email JWE claims. - Update the frontend verify-email handler to navigate to :auth-verify-token when the response carries :invitation-token. Signed-off-by: Andrey Antukh * :bug: Handle email-already-exists error on registration form Add a specific handler for the [:validation :email-already-exists] error code in the registration form's on-error callback. The backend raises this error when an active profile already exists for the requested email, but the frontend was falling through to the generic error message. Now it shows the existing "Email already used" i18n message instead of the generic "Something wrong has happened" toast. * :bug: Reset submitted state on registration form error The on-error handler in the registration form was not resetting the submitted? state, causing the submit button to remain disabled after any error. The completion callback in rx/subs! only fires on success, not on error. Add (reset! submitted? false) at the beginning of the on-error handler so the form becomes submittable again after any error, allowing the user to fix their input and retry. --------- Signed-off-by: Andrey Antukh --- backend/src/app/rpc/commands/auth.clj | 219 ++++++++------ backend/src/app/rpc/commands/verify_token.clj | 5 + .../test/backend_tests/rpc_profile_test.clj | 285 ++++++++++++++++-- frontend/src/app/main/ui/auth/register.cljs | 4 + .../src/app/main/ui/auth/verify_token.cljs | 12 +- 5 files changed, 410 insertions(+), 115 deletions(-) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index c3592d790c..26082e0488 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -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"} @@ -387,25 +407,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}] @@ -414,23 +441,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 conn))] - (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?) @@ -461,48 +481,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) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index a3454f7135..38a3e2b42f 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -72,6 +72,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" diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1cdf16a99f..22f0e966f2 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -514,32 +514,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* @@ -692,6 +749,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) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 917b272dd9..623b25d642 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -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")}) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 16e818e4b2..c401ac8f67 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -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] From 1e1ca82ba53780bf7dcacf6c3fc46dba2f26aae6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 6 May 2026 12:24:55 +0000 Subject: [PATCH 5/6] :books: Add missing changelog entry and document changelog locations Add changelog entry for the fix-incorrect-invitation-token-handling change (PR #9380) under `## 2.15.0 (Unreleased)` > `:bug: Bugs fixed`. Add a `## Changelogs` section to AGENTS.md documenting both changelog locations (main project: `CHANGES.md`, plugins: `plugins/CHANGELOG.md`). Signed-off-by: Andrey Antukh --- AGENTS.md | 11 +++++++++++ CHANGES.md | 1 + 2 files changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 842cd15022..dac88e8261 100644 --- a/AGENTS.md +++ b/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 +`## (Unreleased)` section in the correct category +(`:sparkles: New features & Enhancements` or `:bug: Bugs fixed`). + ## GitHub Operations To obtain the list of repository members/collaborators: diff --git a/CHANGES.md b/CHANGES.md index f4b0796667..b7e85343a2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ ### :bug: Bugs fixed +- Fix incorrect invitation token handling on register process [Github #9380](https://github.com/penpot/penpot/pull/9380) - 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) From df01f7605629a3176755faf28428627ef186e0dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 6 May 2026 14:20:55 +0200 Subject: [PATCH 6/6] :bug: Fix incorrect invitation token handling on register process (#9380) * :bug: Fix incorrect invitation token handling on register process - Reject prepare-register-profile when an active profile already exists for the requested email. - Stop embedding an existing profile's :profile-id into the prepared-register JWE. Profile resolution in register-profile is now done exclusively by email lookup, never by a JWE claim. - Add created? guard to the invitation-success branch in register-profile, so existing profiles (active or not) cannot reach session creation via anonymous registration. Signed-off-by: Andrey Antukh * :recycle: Restructure invitation handling inside register-profile Move the invitation-success branch into the created? sub-cond so it sits alongside the other post-creation branches, making the control flow consistent. - Active new profile + matching invitation: mint session and return :invitation-token (frontend redirects to :auth-verify-token). - Not-yet-active new profile + matching invitation: embed the invitation token inside the verify-email JWE and send the verification email. When the user clicks the link, they get logged in and the frontend completes the team-invitation flow. - Extend send-email-verification! with an optional invitation-token parameter propagated into the verify-email JWE claims. - Update the frontend verify-email handler to navigate to :auth-verify-token when the response carries :invitation-token. Signed-off-by: Andrey Antukh * :bug: Handle email-already-exists error on registration form Add a specific handler for the [:validation :email-already-exists] error code in the registration form's on-error callback. The backend raises this error when an active profile already exists for the requested email, but the frontend was falling through to the generic error message. Now it shows the existing "Email already used" i18n message instead of the generic "Something wrong has happened" toast. * :bug: Reset submitted state on registration form error The on-error handler in the registration form was not resetting the submitted? state, causing the submit button to remain disabled after any error. The completion callback in rx/subs! only fires on success, not on error. Add (reset! submitted? false) at the beginning of the on-error handler so the form becomes submittable again after any error, allowing the user to fix their input and retry. --------- Signed-off-by: Andrey Antukh --- CHANGES.md | 7 + backend/src/app/binfile/v1.clj | 2 +- backend/src/app/db.clj | 2 +- backend/src/app/email.clj | 6 +- backend/src/app/media.clj | 2 +- backend/src/app/metrics.clj | 4 +- backend/src/app/redis.clj | 20 +- backend/src/app/rpc/commands/auth.clj | 219 ++++++++------ backend/src/app/rpc/commands/verify_token.clj | 5 + backend/src/app/storage/s3.clj | 14 +- .../test/backend_tests/rpc_profile_test.clj | 285 ++++++++++++++++-- frontend/src/app/main/ui/auth/register.cljs | 4 + .../src/app/main/ui/auth/verify_token.cljs | 12 +- 13 files changed, 442 insertions(+), 140 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f9fb9fe1a7..6a1a0f8da5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 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 ### :bug: Bugs fixed diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 04b390bb99..75f6f36994 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -40,8 +40,8 @@ [promesa.util :as pu] [yetti.adapter :as yt]) (:import - com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdInputStream + com.github.luben.zstd.ZstdIOException com.github.luben.zstd.ZstdOutputStream java.io.DataInputStream java.io.DataOutputStream diff --git a/backend/src/app/db.clj b/backend/src/app/db.clj index b00f84e3e2..c23ea07524 100644 --- a/backend/src/app/db.clj +++ b/backend/src/app/db.clj @@ -36,11 +36,11 @@ java.sql.Connection java.sql.PreparedStatement java.sql.Savepoint - org.postgresql.PGConnection org.postgresql.geometric.PGpoint org.postgresql.jdbc.PgArray org.postgresql.largeobject.LargeObject org.postgresql.largeobject.LargeObjectManager + org.postgresql.PGConnection org.postgresql.util.PGInterval org.postgresql.util.PGobject)) diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index 44d5cd7e67..b42206dc93 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -22,13 +22,13 @@ [cuerdas.core :as str] [integrant.core :as ig]) (:import - jakarta.mail.Message$RecipientType - jakarta.mail.Session - jakarta.mail.Transport jakarta.mail.internet.InternetAddress jakarta.mail.internet.MimeBodyPart jakarta.mail.internet.MimeMessage jakarta.mail.internet.MimeMultipart + jakarta.mail.Message$RecipientType + jakarta.mail.Session + jakarta.mail.Transport java.util.Properties)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index d54f19ab10..863d2e9df2 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -31,8 +31,8 @@ (:import clojure.lang.XMLHandler java.io.InputStream - javax.xml.XMLConstants javax.xml.parsers.SAXParserFactory + javax.xml.XMLConstants org.apache.commons.io.IOUtils org.im4java.core.ConvertCmd org.im4java.core.IMOperation)) diff --git a/backend/src/app/metrics.clj b/backend/src/app/metrics.clj index 1c7456b7ab..a1f816a304 100644 --- a/backend/src/app/metrics.clj +++ b/backend/src/app/metrics.clj @@ -15,16 +15,16 @@ io.prometheus.client.CollectorRegistry io.prometheus.client.Counter io.prometheus.client.Counter$Child + io.prometheus.client.exporter.common.TextFormat io.prometheus.client.Gauge io.prometheus.client.Gauge$Child io.prometheus.client.Histogram io.prometheus.client.Histogram$Child + io.prometheus.client.hotspot.DefaultExports io.prometheus.client.SimpleCollector io.prometheus.client.Summary io.prometheus.client.Summary$Builder io.prometheus.client.Summary$Child - io.prometheus.client.exporter.common.TextFormat - io.prometheus.client.hotspot.DefaultExports java.io.StringWriter)) (set! *warn-on-reflection* true) diff --git a/backend/src/app/redis.clj b/backend/src/app/redis.clj index 96e6b07be5..dc1bff9669 100644 --- a/backend/src/app/redis.clj +++ b/backend/src/app/redis.clj @@ -24,28 +24,28 @@ [integrant.core :as ig]) (:import clojure.lang.MapEntry - io.lettuce.core.KeyValue - io.lettuce.core.RedisClient - io.lettuce.core.RedisCommandInterruptedException - io.lettuce.core.RedisCommandTimeoutException - io.lettuce.core.RedisException - io.lettuce.core.RedisURI - io.lettuce.core.ScriptOutputType - io.lettuce.core.SetArgs io.lettuce.core.api.StatefulRedisConnection io.lettuce.core.api.sync.RedisCommands io.lettuce.core.api.sync.RedisScriptingCommands io.lettuce.core.codec.RedisCodec io.lettuce.core.codec.StringCodec + io.lettuce.core.KeyValue + io.lettuce.core.pubsub.api.sync.RedisPubSubCommands io.lettuce.core.pubsub.RedisPubSubListener io.lettuce.core.pubsub.StatefulRedisPubSubConnection - io.lettuce.core.pubsub.api.sync.RedisPubSubCommands + io.lettuce.core.RedisClient + io.lettuce.core.RedisCommandInterruptedException + io.lettuce.core.RedisCommandTimeoutException + io.lettuce.core.RedisException + io.lettuce.core.RedisURI io.lettuce.core.resource.ClientResources io.lettuce.core.resource.DefaultClientResources + io.lettuce.core.ScriptOutputType + io.lettuce.core.SetArgs io.netty.channel.nio.NioEventLoopGroup + io.netty.util.concurrent.EventExecutorGroup io.netty.util.HashedWheelTimer io.netty.util.Timer - io.netty.util.concurrent.EventExecutorGroup java.lang.AutoCloseable java.time.Duration)) diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index c3592d790c..26082e0488 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -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"} @@ -387,25 +407,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}] @@ -414,23 +441,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 conn))] - (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?) @@ -461,48 +481,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) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index a3454f7135..38a3e2b42f 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -72,6 +72,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" diff --git a/backend/src/app/storage/s3.clj b/backend/src/app/storage/s3.clj index ef56e8a9b4..9322de70e6 100644 --- a/backend/src/app/storage/s3.clj +++ b/backend/src/app/storage/s3.clj @@ -30,21 +30,18 @@ java.nio.file.Path java.time.Duration java.util.Collection - java.util.Optional java.util.concurrent.atomic.AtomicLong + java.util.Optional org.reactivestreams.Subscriber software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider - software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.core.async.AsyncRequestBody software.amazon.awssdk.core.async.AsyncResponseTransformer software.amazon.awssdk.core.async.BlockingInputStreamAsyncRequestBody software.amazon.awssdk.core.client.config.ClientAsyncConfiguration + software.amazon.awssdk.core.ResponseBytes software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient software.amazon.awssdk.http.nio.netty.SdkEventLoopGroup software.amazon.awssdk.regions.Region - software.amazon.awssdk.services.s3.S3AsyncClient - software.amazon.awssdk.services.s3.S3AsyncClientBuilder - software.amazon.awssdk.services.s3.S3Configuration software.amazon.awssdk.services.s3.model.Delete software.amazon.awssdk.services.s3.model.DeleteObjectRequest software.amazon.awssdk.services.s3.model.DeleteObjectsRequest @@ -54,9 +51,12 @@ software.amazon.awssdk.services.s3.model.ObjectIdentifier software.amazon.awssdk.services.s3.model.PutObjectRequest software.amazon.awssdk.services.s3.model.S3Error - software.amazon.awssdk.services.s3.presigner.S3Presigner software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest - software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest)) + software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest + software.amazon.awssdk.services.s3.presigner.S3Presigner + software.amazon.awssdk.services.s3.S3AsyncClient + software.amazon.awssdk.services.s3.S3AsyncClientBuilder + software.amazon.awssdk.services.s3.S3Configuration)) (def ^:private max-retries "A maximum number of retries on internal operations" diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1cdf16a99f..22f0e966f2 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -514,32 +514,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* @@ -692,6 +749,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) diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 917b272dd9..623b25d642 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -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")}) diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 16e818e4b2..c401ac8f67 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -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]