Address CodeRabbit review on #9380. The active-link filter already keeps
the page__project_pages join to a single row via the partial-unique
constraint, but add distinct() to the detail get() as defense in depth so
the join can never surface MultipleObjectsReturned (a 500) even if that
invariant were ever violated.
Co-authored-by: Plane AI <noreply@plane.so>
Address CodeRabbit + Copilot review on #9380: projects__id=project_id
matched even soft-deleted ProjectPage links, so a page removed from the
project (link revoked) would still pass, and the version detail get()
could raise MultipleObjectsReturned on active + soft-deleted rows.
Put both conditions on the same project_pages relation in one filter so
they match a single ProjectPage row that is active:
project_pages__project_id=project_id + project_pages__deleted_at__isnull
=True. The partial-unique constraint (project, page WHERE deleted_at IS
NULL) then guarantees at most one row, so get() stays unambiguous and the
list needs no distinct(). Add a revoked-link regression test.
Co-authored-by: Plane AI <noreply@plane.so>
ProjectPagePermission verified the caller was a member of the URL
project_id but then resolved the page by workspace + page_id only, and
PageVersionEndpoint filtered versions the same way. A member of one
project could read the page versions of a public page belonging to a
different project in the same workspace via that project's URL
(GHSA-g49r-p85q-qq2w / GHSA-ghcr-frqr-6pqr).
- Scope the page lookup in ProjectPagePermission to projects__id via the
ProjectPage M2M (both app/ and utils/ copies); deny when the page does
not belong to the URL project.
- Scope PageVersionEndpoint list/detail querysets to
page__projects__id=project_id (defense in depth); distinct() on the
list guards against active + soft-deleted ProjectPage duplicates.
- Add contract regression tests (fail-before verified).
Co-authored-by: Plane AI <noreply@plane.so>
Bot service accounts (User.is_bot=True, e.g. the WORKSPACE_SEED bot) are
internal identities meant to act only through API tokens. Nothing stopped
one from being driven through the interactive login flow if its email was
known, letting a human assume a service identity.
Reject bot accounts at the shared login chokepoint,
Adapter.complete_login_or_signup(), right beside the existing
deactivated-account check. This covers every interactive provider in one
place: email/password, magic code, and all OAuth providers (Google, GitHub,
GitLab, Gitea) across both the app and space surfaces. Bot API-token access
is left untouched, since that is how bots are meant to operate.
Also add a defense-in-depth is_bot guard to InstanceAdminSignInEndpoint,
which mints its own admin session outside the chokepoint (a bot is never an
InstanceAdmin today, so this is not currently reachable, but it closes the
path regardless).
Surface the rejection with a new dedicated error code
BOT_USER_LOGIN_FORBIDDEN (5017), plumbed into the app and space frontend
error helpers as well as the shared @plane/constants and @plane/utils
packages (message map + banner-alert list) so any consumer of the shared
auth-error handler renders it correctly. The admin path reuses the existing
ADMIN_AUTHENTICATION_FAILED code so it discloses no bot-specific error.
Add contract regression tests: a bot blocked via password and via magic
code, a bot blocked at the admin sign-in endpoint, and a non-bot control
that still logs in.
* [WEB-7894] fix: eliminate TOCTOU race in InstanceAdminSignUp (GHSA-p548-28jp-wr4p)
Two concurrent POST requests to InstanceAdminSignUpEndpoint could both
pass the "no admin yet" check before either created the InstanceAdmin
row, resulting in dual instance admins.
Fix: wrap the check + create in transaction.atomic() with
select_for_update() on the Instance singleton row. The pre-check
(is_setup_done / existing admin) outside the lock is kept as a fast
early-exit for the common post-setup path. The re-check inside the
lock is the authoritative guard; user_login() is kept outside the
transaction to avoid holding the DB lock during session writes.
Co-authored-by: Plane AI <noreply@plane.so>
* fix: use global InstanceAdmin.objects.exists() guard (coderabbit)
The pre-check and re-check inside the atomic block were scoped to
filter(instance=instance), which could be bypassed if a stray second
Instance row existed. Changed both guards to InstanceAdmin.objects.exists()
to match the original global check and make them consistent with each other.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
All three V2 issue attachment handlers (PATCH, DELETE, GET single) looked
up FileAsset by (pk, workspace, project_id) only — issue_id in the URL
was silently ignored. Any project member could target another user's
attachment UUID using their own issue_id, and PATCH would transfer
ownership via unconditional created_by = request.user.
Add issue_id=issue_id to all three FileAsset.objects.get() calls so the
lookup is correctly scoped to the attachment's owning issue. Remove the
created_by overwrite in PATCH — created_by is set at creation time and
must not be reassigned by a subsequent upload-confirm call.
Co-authored-by: Plane AI <noreply@plane.so>
ProjectJoinEndpoint.get() was AllowAny and used ProjectMemberInviteSerializer
(fields = "__all__"), leaking the invitee's email and token to anyone who
knew the workspace slug, project ID, and invite UUID (GHSA-2r58-hgv7-635q).
Introduce ProjectMemberInvitePublicSerializer with an explicit safe field list
that excludes `email` and `token`, and swap it in for the public GET endpoint.
The full serializer is retained for authenticated admin viewsets.
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-7945] fix(security): prevent shell injection in feature-deployment.yml
Bind `github.event.inputs.base_tag_name` and `env.TARGET_BRANCH` to
step-level env vars (INPUT_BASE_TAG_NAME, GH_TARGET_BRANCH) and
reference them as shell variables in the run: script instead of
interpolating ${{ }} expressions inline.
GitHub Actions expands ${{ }} before the shell executes, so a crafted
base_tag_name value could inject arbitrary commands into the runner
context (GHSA-gfj7-g3wj-2p5f). Using env: breaks the injection path —
the value is set as a process environment variable, never as raw shell
text.
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-7945] fix: strip CR/LF from base_tag_name before writing to GITHUB_OUTPUT
A newline embedded in the value would let an attacker forge additional
output keys in the line-delimited $GITHUB_OUTPUT file (output injection).
Strip \r and \n via tr before writing AIO_BASE_TAG, addressing the
CodeRabbit finding on PR #9334.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* fix: enforce workspace membership on entity-search endpoint (GHSA-32q3-mqpc-3mhv)
SearchEndpoint required authentication but did not verify the requesting user
was a member of the queried workspace. Any authenticated Plane user could
enumerate members across workspaces they don't belong to by guessing slugs.
Add a WorkspaceMember guard at the top of get() — returns 403 if the user is
not an active member of the target workspace. Brings OSS to parity with EE,
which already had this protection via @can(WorkspacePermissions.VIEW).
Co-authored-by: Plane AI <noreply@plane.so>
* refactor(security): replace inline WS membership check with WorkspaceUserPermission
Use the existing WorkspaceUserPermission permission class on SearchEndpoint
instead of a manual WorkspaceMember.objects.filter() guard inside the
method body. Enforcement behaviour is unchanged (GHSA-32q3-mqpc-3mhv).
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* [WEB-7776] fix(security): scope FileAsset queries to prevent cross-project IDOR (Cluster F)
Multiple asset endpoints were missing project-level scoping on FileAsset
queryset filters, allowing authenticated users to access, mark-uploaded,
or restore assets belonging to other projects/workspaces.
- ProjectBulkAssetEndpoint.post: add project_id= scope to asset filter
- EntityAssetEndpoint.get/patch: add project_id=deploy_board.project_id
- AssetRestoreEndpoint.post: add project_id=deploy_board.project_id
- FileAssetEndpoint (V1): add workspace membership check on get/post/delete
- FileAssetViewSet.restore (V1): add workspace membership check
- WorkspaceFileAssetEndpoint.post: gate WORKSPACE_LOGO on ADMIN role
- DuplicateAssetEndpoint.post: restrict source asset to same workspace
Fixes GHSA-r2hw, GHSA-jh4v, GHSA-8688, GHSA-3hrj and related advisories.
Co-authored-by: Plane AI <noreply@plane.so>
* refactor(security): replace inline membership checks with WorkspaceMemberPermission class
Add WorkspaceMemberPermission to workspace.py — resolves workspace by
'workspace_id' UUID or 'slug' kwarg, covering the mixed URL patterns on
FileAssetEndpoint. Apply to FileAssetEndpoint and FileAssetViewSet so
membership enforcement lives in the permission layer, not inside each
method handler.
Co-authored-by: Plane AI <noreply@plane.so>
* refactor: remove dead 404 guard in FileAssetEndpoint.post()
WorkspaceMemberPermission denies requests for non-existent slugs before
the view method runs, making the filter().first() + if not workspace
branch unreachable. Switch to .get() so any TOCTOU race still surfaces
as a 404 via ObjectDoesNotExist.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* fix(auth): restore activation flow and narrow deactivation guard (GHSA-rmmf-rj2q-3rrg)
PR #9290 introduced two regressions in adapter/base.py:
1. is_signup = bool(user) was inverted — True when the user EXISTS means
the IDP sync ran on signup instead of login, and the callback received
the wrong value. Fixed to is_signup = not bool(user) matching EE.
2. The deactivation check blocked ALL inactive users, including accounts
provisioned with is_active=False that have never completed a first
login. Fixed by adding `and user.last_login_time is not None` — only
accounts that have previously logged in (and were then explicitly
deactivated by an admin) are rejected. Provisioned/never-logged-in
accounts still pass through to save_user_data().
3. Restore is_active=True and user_activation_email in save_user_data()
so provisioned accounts are properly activated on first login.
Co-authored-by: Plane AI <noreply@plane.so>
* fix(auth): save before email, use last_logout_time as deactivation discriminator
Two CR fixes on PR #9304:
1. save_user_data(): capture was_inactive flag, save() first, then send
activation email as a best-effort side-effect so a failed enqueue
cannot abort account activation.
2. complete_login_or_signup(): switch deactivation discriminator from
last_login_time to last_logout_time. The deactivation endpoint always
sets last_logout_time, making it a direct signal of explicit
deactivation. A provisioned account that was never deactivated has
last_logout_time=None and is correctly allowed through for first login,
even if it also has no last_login_time.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-rmmf-rj2q-3rrg: save_user_data() was unconditionally setting
is_active=True on every login, silently reactivating any admin-deactivated
account. Fix: add an early guard in complete_login_or_signup() that raises
USER_ACCOUNT_DEACTIVATED (5019) before any session or save logic if the
existing user's is_active=False. Remove the is_active=True assignment and
the associated user_activation_email call from save_user_data(). Also
remove the now-unused user_activation_email and base_host imports.
GHSA-wjgv-cq7w-258v: WorkspaceOwnerPermission in both app/permissions/
and utils/permissions/ was filtering WorkspaceMember without is_active=True,
allowing a deactivated workspace owner/admin to retain API access. Add
is_active=True to both copies to match every other permission class.
Co-authored-by: Plane AI <noreply@plane.so>
* fix: prevent ORM order_by injection via user-supplied query params (GHSA-2r95, GHSA-w45q)
Add field-name allowlists and a sanitize_order_by() utility in order_queryset.py.
All allowlists are centralised there; each call site imports the named constant
so there are no inline sets scattered across view files.
- order_queryset.py: ISSUE_ORDER_BY_ALLOWLIST, INTAKE_ISSUE_ORDER_BY_ALLOWLIST,
ACTIVITY_ORDER_BY_ALLOWLIST, PROJECT_ORDER_BY_ALLOWLIST, VIEW_ORDER_BY_ALLOWLIST,
NOTIFICATION_ORDER_BY_ALLOWLIST + sanitize_order_by() utility; validation added
at the top of order_issue_queryset() — fixes all callers including the
unauthenticated ProjectIssuesPublicEndpoint (GHSA-w45q)
- api/views/cycle.py, api/views/module.py: cycle/module issue list endpoints
- api/views/issue.py: IssueActivity list and detail endpoints
- app/views/intake/base.py: IntakeIssue list
- app/views/view/base.py: saved-view list
- app/views/notification/base.py: notification paginator
- app/views/project/base.py: project list paginator
- app/views/user/base.py, app/views/workspace/user.py: activity paginators
Closes WEB-7813
Co-authored-by: Plane AI <noreply@plane.so>
* fix: harden sanitize_order_by against multi-dash malformed inputs
lstrip("-") stripped all leading dashes, allowing "--created_at" to
pass the allowlist check unchanged and reach .order_by() as a malformed
token (causing FieldError). Now strips only one leading dash; any
remaining dash prefix is rejected to the safe default.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
* fix: remove hardcoded SECRET_KEY from community deployment manifests (GHSA-cmwv-pjmw-8483)
Replace the publicly-known default SECRET_KEY and LIVE_SERVER_SECRET_KEY values
in AIO and CLI community deployment manifests with a safe placeholder.
- deployments/aio: variables.env now ships with placeholder values;
start.sh auto-generates a random key on first boot (or on upgrade from the old
insecure default) and persists it in plane.env across restarts
- deployments/cli: variables.env ships with placeholder; docker-compose.yml
fallbacks that referenced the publicly-known default are removed
- apps/api/plane/settings/common.py: SECRET_KEY resolution now uses `or`
so an empty env var falls back to get_random_secret_key() (not ""); adds a
startup warning if the known insecure default or placeholder is detected
Closes WEB-7805
Co-authored-by: Plane AI <noreply@plane.so>
* fix: use logger.critical instead of print for insecure SECRET_KEY warning
Address code review feedback — replace module-level print() with _logger.critical()
and move _logger definition before the SECRET_KEY block to avoid duplicate assignment.
Also removes the now-unused `import sys`.
Co-authored-by: Plane AI <noreply@plane.so>
---------
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-933r-rxg8-f3h2 — EstimatePointEndpoint.create trusted the
estimate_id URL parameter without verifying it belonged to the caller's
workspace and project. An authenticated user in project A could inject
estimate points into any other workspace's estimate by supplying a
foreign estimate_id.
Fix: added a workspace+project scoped Estimate ownership check before
EstimatePoint.objects.create().
GHSA-933r-rxg8-f3h2 (destroy) — old_estimate_point was fetched with
pk only (unscoped), allowing cross-tenant key disclosure and
manipulation during the key-rearrangement step.
Fix: scoped the old_estimate_point lookup to estimate_id + project_id +
workspace__slug; added 404 guard for missing/foreign points.
Note: BulkEstimatePointEndpoint.partial_update (GHSA-vm3j-5j49-gwrf)
was already correctly scoped at lines 116 and 125-130 — no change needed.
Co-authored-by: Plane AI <noreply@plane.so>
GHSA-6qrq-f73q-r67j / GHSA-j9pv-f5wm-p4g2 — IssueCommentSerializer in
both app and api layers stored comment_html without sanitization. The app
layer had no validate() at all; the api layer only ran lxml structural
normalization which does not strip XSS payloads.
Fix: both serializers now call validate_html_content() (nh3-backed) in
their validate() methods, replacing the raw value with sanitized HTML.
GHSA-hh2r-3hwp-mvq3 — space/views/intake.py and api/views/intake.py
both used bare Issue.objects.create() with description_html taken
directly from request data, bypassing any serializer validation.
Fix: both paths now call validate_html_content() and pass the sanitized
value to Issue.objects.create(). Falls back to "<p></p>" if sanitizer
returns None (empty/invalid input).
The nh3 sanitizer (validate_html_content in content_validator.py) was
already present and used by IssueCreateSerializer — this change extends
coverage to the two remaining unsanitized comment and intake paths.
Co-authored-by: Plane AI <noreply@plane.so>
- Add WorkSpaceMemberInvitePublicSerializer that excludes token and
invite_link; use it in WorkspaceJoinEndpoint.get() so an unauthenticated
caller cannot retrieve the acceptance token from the GET endpoint
(GHSA-86mg-259g-pwgg / GHSA-gf48-p6jp-cwc4).
- Require authentication and verify request.user.email matches the
invited email before accepting a workspace invitation so an attacker
who registers with the invited address cannot hijack the invite
(GHSA-4vj8-p63v-8p24).
Co-authored-by: Plane AI <noreply@plane.so>
* fix: scope workspace user preference filter to current user
Without user=request.user on the PATCH filter, the ORM could match
another user's preference record in the same workspace, causing
pin/unpin state to leak across users or silently fail to persist.
Fixes#9260
Signed-off-by: okxint <cashmein.eth@gmail.com>
* test: add regression coverage for workspace user preference scoping (#9260)
Adds contract tests for the sidebar preference PATCH endpoint:
- test_patch_only_updates_requesting_users_preference: in a multi-member
workspace, a member's PATCH must update only their own preference row,
never another member's. Fails against the pre-fix code (the unscoped
.first() mutates the most-recently-created row regardless of user).
- test_patch_updates_own_preference: baseline that a member's PATCH
persists to their own row.
Verified RED on the unpatched view and GREEN with the user=request.user
filter from #9261.
* fix(api): wrap long line to satisfy ruff E501 in user preference view
---------
Signed-off-by: okxint <cashmein.eth@gmail.com>
Co-authored-by: okxint <cashmein.eth@gmail.com>
* fix: Use APP_DOMAIN env var for bot user email instead of hardcoded plane.so
Signed-off-by: okxint <cashmein.eth@gmail.com>
* use settings.WEB_URL instead of APP_DOMAIN env var for bot email domain
---------
Signed-off-by: okxint <cashmein.eth@gmail.com>
* fix(api): require at least one alphanumeric char in workspace name
Workspace name validation was enforced only on the frontend
(validateWorkspaceName), which gates the UI submit but is bypassable
via a direct API call. The backend WorkSpaceSerializer.validate_name
only rejected URLs, so a symbol-only name like "-_________-" could
still be saved via create or the rename (partial_update) path.
Add a Unicode-aware has_alphanumeric() helper and enforce it in both
the app and instance/license workspace serializers, mirroring the
frontend HAS_ALPHANUMERIC_REGEX (/[\p{L}\p{N}]/u) added in #9263.
International names (日本語, José, محمد) still pass since str.isalnum()
covers all scripts.
Adds unit tests covering symbol-only rejection and international
acceptance on both serializers.
Refs #9255
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
* fix(api): reject URLs in instance workspace name for parity
Address CodeRabbit review on #9278: the instance/license
WorkspaceSerializer.validate_name rejected symbol-only names but, unlike
the app-level WorkSpaceSerializer, still accepted names containing URLs.
Add the same contains_url() guard (imported from plane.utils.url, not
content_validator) so both workspace-create paths validate identically.
Add unit tests asserting URL-containing names are rejected on both
serializers.
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
---------
Signed-off-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
* fix(security): scope issue ID validation to workspace/project in bulk endpoints
Prevents cross-tenant IDOR by filtering incoming issue IDs through
workspace+project scope before bulk_create/bulk_update in:
- CycleIssueListCreateAPIEndpoint: validate new_issues against workspace+project (GHSA-22g9-9xfv-q3fr)
- SubIssuesEndpoint: validate sub_issue_ids against workspace (GHSA-38vj-gf85-7q5x)
- IssueRelationListCreateAPIEndpoint: validate issues against workspace (GHSA-8cvv-8jh5-g6mj)
- ModuleIssueListCreateAPIEndpoint: already scoped at line 673, no change needed (GHSA-x5c5-hmvm-94v9)
Co-authored-by: Plane AI <noreply@plane.so>
* fix(security): extend IDOR scope validation to app-layer endpoints
Same cross-tenant IDOR fix applied to the app/views/ counterparts
which are used by the web frontend (api/views/ covered in previous commit):
- app/views/cycle/issue.py: filter new_issues to workspace+project (GHSA-22g9-9xfv-q3fr)
- app/views/module/issue.py: filter issues to workspace+project before bulk_create (GHSA-x5c5-hmvm-94v9)
- app/views/issue/relation.py: filter issues to workspace before bulk_create (GHSA-8cvv-8jh5-g6mj)
Co-authored-by: Plane AI <noreply@plane.so>
* chore: remove advisory ID references from code comments
---------
Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
`COMPANY_NAME_REGEX` blocks disallowed chars but accepts symbol-only
strings like `-_________-` since `-` and `_` are in the allowed set.
Add `HAS_ALPHANUMERIC_REGEX` and check it in `validateWorkspaceName`
and `validateCompanyName` so inputs with no letter or digit are rejected.
Fixesmakeplane/plane#9255
Signed-off-by: okxint <cashmein.eth@gmail.com>
* fix(security): scope cascade deletes to workspace in BulkDeleteIssuesEndpoint
CycleIssue and ModuleIssue cascade deletes used raw issue_ids from the
request instead of the already workspace+project scoped issues queryset,
allowing cross-workspace deletion of related records.
Fixes GHSA-6cw7-h92q-p9hg and GHSA-2rr4-rp7r-32p4.
GHSA-7q7r-mrr4-2wwx (sub-issue parent reassign) covered in WEB-7727.
Co-authored-by: Plane AI <noreply@plane.so>
* chore: remove advisory ID reference from code comment
---------
Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
Resolves the sole open Dependabot alert (GHSA-gv7w-rqvm-qjhr, high):
esbuild <0.28.1 lacks binary-integrity verification in its Deno install
path, enabling RCE via NPM_CONFIG_REGISTRY. The pnpm `overrides` entry
pinned the entire tree to esbuild 0.25.0; bump it to 0.28.1, which fixes
all transitive paths (vite, vitest, tsx, vite-node, esbuild-register).
Also bump turbo 2.9.14 -> 2.9.18 in the workspace catalog and the four
pinned production Dockerfiles (web, live, admin, space).
Verified: `pnpm audit` clean, admin react-router/vite build succeeds,
@plane/codemods vitest suite 33/33 passing.
The custom API key authentication only verified that the APIToken row was
active and unexpired; it never checked the owning user's is_active flag.
DRF's IsAuthenticated only checks user.is_authenticated (always True for a
real User), so a user whose account was deactivated could keep using a
previously issued API key indefinitely.
Add user__is_active=True to the validate_api_token() lookup so a token tied
to a disabled account is treated as invalid (a generic AuthenticationFailed,
avoiding account-state disclosure). Applied to both the external API
middleware (plane/api) and the identical, currently unused copy in
plane/app to prevent the gap from being reintroduced.
Adds unit coverage on validate_api_token and an end-to-end contract test
proving GET /api/v1/users/me/ is denied once the account is deactivated.
User-controlled values (work item titles, labels, etc.) were written
raw into openpyxl worksheet cells, so values beginning with = were
stored as live formula cells in exported XLSX files. Apply the same
formula-trigger sanitization already used for CSV exports to XLSX
cell values and header rows in both export formatters, and sanitize
CSV header rows in the porters formatter for parity.
Add react-doctor for React lint/a11y/perf/architecture diagnostics:
- Add react-doctor devDependency and "doctor" npm script
- Add .claude/skills/react-doctor skill for local triage workflow
- Add GitHub Actions workflow to scan PRs and track health score
* fix: bump react-router and vitest to resolve Dependabot advisories
Resolves 6 open Dependabot alerts (all npm, manifest pnpm-lock.yaml):
- react-router 7.12.0 -> 7.15.0 (fixes GHSA-8x6r-g9mw-2r78 [high],
GHSA-49rj-9fvp-4h2h [high], GHSA-8646-j5j9-6r62 [high],
GHSA-2j2x-hqr9-3h42 [medium], GHSA-f22v-gfqf-p8f3 [medium])
- vitest 4.0.x -> 4.1.x (fixes GHSA-5xrq-8626-4rwp [critical])
Aligned lockstep siblings to avoid peer-dependency mismatches:
@react-router/dev|node|serve -> 7.15.0, @vitest/coverage-v8 -> ^4.1.0.
Edited catalog entries in pnpm-workspace.yaml and regenerated
pnpm-lock.yaml; verified with pnpm install --frozen-lockfile.
* fix: raise vitest catalog floor to ^4.1.8 to match security advisory
The critical advisory GHSA-5xrq-8626-4rwp is patched in vitest 4.1.8, but
the catalog specifiers were ^4.1.0, which permits resolving to vulnerable
4.1.0-4.1.7. Align the floor with the documented patched version for vitest
and @vitest/coverage-v8 so a future lockfile refresh cannot reintroduce a
vulnerable Vitest stack. Resolved version is unchanged (4.1.8).
The public REST API GenericAssetEndpoint (/api/v1/workspaces/<slug>/assets/)
declared no permission class, inheriting only IsAuthenticated. Since
APIKeyAuthentication does not bind a token to a workspace and the workspace is
read straight from the URL slug, any valid Personal Access Token could read
(GET), create (POST), and modify (PATCH) assets in a workspace the caller is
not a member of — a cross-workspace IDOR, the public-API sibling of the
CVE-2026-46558 dashboard asset fix.
Add permission_classes = [WorkspaceUserPermission] so every method requires
active workspace membership, matching the dashboard fix semantics. Also add
contract regression tests covering cross-workspace GET/POST/PATCH (now 403)
and a positive control confirming members retain access.
Also ignore the local /security/ advisory notes folder.
* fix(api): rate-limit magic-code verification and bound per-token attempts
The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code
(900k-value space, 600s TTL) but never increment a failure counter on a
wrong-code verify and extend django.views.View rather than DRF APIView,
so DRF's AuthenticationThrottle never runs against them. The space-side
generate endpoint also lacked throttle_classes. Combined, this allowed
an unauthenticated attacker who knew a victim's email to brute-force
the code within the TTL window and log in as the victim.
- Add MAX_VERIFY_ATTEMPTS=5 in MagicCodeProvider.set_user_data: failed
comparisons now persist verify_attempts in Redis under the remaining
TTL and, on hitting the limit, delete the key and raise
EMAIL_CODE_ATTEMPT_EXHAUSTED. This is the load-bearing fix - it caps
total attempts per issued token regardless of request rate.
- Add authentication_throttle_allows() so plain Django Views can apply
AuthenticationThrottle without converting to APIView (would change
CSRF + request-parsing semantics for the redirect-flow endpoints).
- Apply the throttle to MagicSignIn/UpEndpoint and the space variants;
add throttle_classes to MagicGenerateSpaceEndpoint to match its app
sibling.
Refs GHSA-9pvm-fcf6-9234.
* fix(api): make verify-attempt increment atomic, expose throttle rate via env
Address PR review feedback:
- Replace the JSON read-modify-write of verify_attempts with a Lua
EVAL script that INCRs a dedicated counter key and EXPIREs it only
on the first increment. The previous round-trip was racy: parallel
wrong-code requests could read the same value and both write the
same incremented count, letting an attacker exceed MAX_VERIFY_ATTEMPTS
under concurrency. Counter is now reset on each new token issuance
and cleared on successful verify / exhaustion.
- Make AuthenticationThrottle.rate configurable via the
AUTHENTICATION_RATE_LIMIT env var (default 10/minute, down from 30
to tighten the budget on unauth auth-adjacent endpoints). Document
it in deployments/aio and deployments/cli variables.env.
* test(api): cover magic-code attempt cap, counter reset, and auth throttle
Add the contract tests called out in the PR test plan:
- TestMagicSignInVerifyAttempts:
- test_exhausted_after_max_wrong_attempts: after MAX_VERIFY_ATTEMPTS
wrong codes the next verify redirects with EMAIL_CODE_ATTEMPT_
EXHAUSTED_SIGN_IN and both Redis keys are deleted; a follow-up
verify reports EXPIRED.
- test_counter_increments_on_each_wrong_attempt: the dedicated
verify_attempts counter advances by exactly one per wrong POST,
matching the atomic Lua INCR.
- test_counter_resets_on_token_regeneration: regenerating the
magic-link clears the counter so the user isn't pre-locked-out by
a prior session's wrong attempts.
- TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts:
the sign-up endpoint returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP on
the exhausting attempt.
- TestAuthenticationThrottle: exercises authentication_throttle_allows
on the plain-View redirect-flow endpoints by patching the rate down
and asserting RATE_LIMIT_EXCEEDED is appended to the redirect URL
once the per-IP budget is exceeded, for both magic-sign-in and
magic-sign-up.
Each new class clears Django cache (DRF throttle storage) and the
per-email Redis keys around every test so runs are independent.
* fix(api): clamp remaining_ttl to >=1 for verify-attempt counter EXPIRE
ri.ttl() returns 0 when the token has less than one second remaining
(Redis floors to whole seconds). The previous clamp only caught
None and < 0, so a sub-second TTL would pass through and the Lua
script's EXPIRE counter 0 would immediately delete the key — letting
an attacker bypass MAX_VERIFY_ATTEMPTS during the final second of the
token's life. Switch the comparison to <= 0.
Narrow real-world impact (sub-second window, throttle still bounds
the rate) but the cap should hold regardless of timing.
* fix: bump npm deps to resolve Dependabot advisories
Resolve 8 open Dependabot alerts (all npm, in pnpm-lock.yaml) by bumping
the affected packages in pnpm-workspace.yaml and regenerating the lockfile:
- axios 1.15.2 -> 1.16.0 (catalog): CVE-2026-44494/44492/44490/44489
- tmp -> 0.2.6 (override): CVE-2026-44705 path traversal
- ws 8.x -> 8.20.1 (catalog + scoped override): CVE-2026-45736
- qs 6.14.2 -> 6.15.2 (override): CVE-2026-8723 DoS
- brace-expansion 5.0.5 -> 5.0.6 (override): CVE-2026-45149 DoS
brace-expansion and qs were pinned to their vulnerable versions in the
overrides block, so the pins had to be bumped directly. ws is scoped to
the 8.x major (ws@7.5.10 is below the vulnerable >=8.0.0 floor). All bumps
are semver-compatible patch/minor upgrades; no source changes required.
* fix: use named axios `create` import after 1.16.0 bump
axios 1.16.0 newly exposes `create` as a named export, so oxlint's
import/no-named-as-default-member rule now flags `axios.create(...)`.
That added one warning to @plane/services (7 > its --max-warnings=6
baseline) and to apps/web and apps/live, failing check:lint — surfaced
on this PR because the lockfile change busts Turbo's lint cache.
Switch the three `axios.create(...)` call sites to a named `{ create }`
import. `create` is a real value+type export in axios 1.16.0 (verified
via tsc). isCancel/CancelToken are left as `axios.*`: CancelToken is
only a type export (cannot be a value import under verbatimModuleSyntax)
and both were already counted within the existing baselines.
Verified locally: full `pnpm check:lint` (16/16) and `check:types`
(15/15) pass.
The web/admin/space Docker image builds fail at the Vite/PostCSS step with
"Cannot find module '@tailwindcss/postcss'". These apps load the shared
@plane/tailwind-config/postcss.config.js, which references the @tailwindcss/postcss
plugin by name, but the plugin was only declared as a dependency of
packages/tailwind-config.
The Docker build installs via turbo prune + 'pnpm fetch' + 'pnpm install --offline',
which lays out node_modules so PostCSS resolves the plugin relative to the app
directory (apps/<app>), where it is not reachable. A plain 'pnpm install' resolves
it from tailwind-config's context instead, which is why local builds passed and
masked the issue.
Declare @tailwindcss/postcss as a direct devDependency of the three apps that run
Vite/PostCSS so it is symlinked into each app's node_modules and resolves under the
isolated linker regardless of install flow.
Verified by reproducing the exact Docker flow (prune -> fetch -> --frozen-lockfile
offline install -> build) for admin, space and web: all install in sync and build
successfully with full Tailwind CSS output.
* fix(api): return HTTP response from dispatch() exception handler
BaseAPIView.dispatch() and BaseViewSet.dispatch() built the proper
error Response via handle_exception() but returned the raw exception
object instead, causing Django to raise
"TypeError: 'Exception' object is not a valid HTTP response".
Fix all six occurrences across the api, app, license and space view
bases, and add a regression test covering every affected base class.
Fixes#9157
* chore(api): add copyright header to tests/unit/views/__init__.py
The empty package init file was missing the AGPL copyright header,
failing the Copy Right Check CI (addlicense -check on all tracked
.py files).
Centralize every external dependency version in the pnpm catalog
(pnpm-workspace.yaml) and reference them via `catalog:` across all
apps and packages. Packages that previously used differing versions
were unified to the highest (notably @react-pdf/renderer ^3.4.5 ->
^4.3.0 in apps/web).
* fix(api): harden webhook & link-unfurl SSRF (advisory clusters A/B/C)
Resolves three overlapping SSRF advisory clusters around webhook delivery
and work-item link unfurling:
- Cluster A (private-IP validation + PATCH bypass): the webhook PATCH
handler passed context={request: request} (the request object as the
dict key) so the loopback/disallowed-domain guard silently no-op'd —
now context={"request": request}. Hardened IP classification
(is_blocked_ip) to also block multicast, unspecified, CGNAT
(100.64.0.0/10), and IPv4 embedded in IPv6 transition addresses
(IPv4-mapped, NAT64, 6to4, Teredo), robust across Python versions.
- Cluster B (DNS-rebinding TOCTOU): validators resolved DNS, then
requests resolved it again at connect time. New pinned-IP client
(plane/utils/url_security.py) resolves+validates once and connects to
the validated IP literal so urllib3 performs no second lookup, while
preserving Host header, TLS SNI and certificate verification against
the real hostname.
- Cluster C (redirect SSRF): webhook delivery never follows redirects;
the link crawler follows them manually, re-resolving + re-validating +
re-pinning every hop.
Also: pin requests==2.33.0 in base.txt (imported directly; the pinning
adapter needs the >=2.32 get_connection_with_tls_context hook), and log
webhook URL-validation rejections to WebhookLog instead of swallowing
them.
Tests: new test_url_security.py (pinning, rebinding, redirect
re-validation, IP edge cases, TLS SNI) + updated link-task tests.
Full unit suite: 178 passed.
* fix(api): block OAuth avatar SSRF + add per-advisory SSRF regression tests
Verified every SSRF-class advisory against the current code. The webhook /
link / favicon reports — including the published CVE-2026-30242 and
CVE-2026-39843 and the newer "still bypassable" reports (DNS rebinding
GHSA-3856/-fgcv/-9292/-whh3/-4mjx/-6p39/-fv24/-8wvv, IP-classification gaps
GHSA-75fg, redirect GHSA-6v37/-jw6g/-mq87) — are resolved by the pinned-IP
client + hardened classifier in this branch.
The one SSRF family still unresolved was the OAuth avatar path:
download_and_upload_avatar() fetched the provider-supplied avatar_url with a
raw requests.get (no IP validation, default redirect following), so an
attacker-controlled avatar could reach internal addresses and be exfiltrated
via the static-asset endpoint (GHSA-cv9p-325g-wmv5, and the avatar hop of the
Gitea SSRF GHSA-hx79-5pj5-qh42). It now uses pinned_fetch_following_redirects,
which validates + pins every hop and blocks internal targets.
Adds test_ssrf_advisories.py: a per-advisory regression map covering webhook
IP validation, the PATCH context-key guard, webhook DNS rebinding, webhook
redirect, favicon redirect + rebinding, and OAuth avatar SSRF.
docker compose test: 199 unit tests pass.
* fix(api): address PR review feedback on the SSRF pinned client
- url_security: preserve URL-embedded credentials (user:pass@host) as Basic
Auth instead of silently dropping them when rewriting to the IP literal
(Copilot); bracket IPv6-literal hostnames in the Host header (Copilot);
add stream=True support that keeps the session open until the response is
closed, and release intermediate redirect hops.
- ip_address / work_item_link_task: treat UnicodeError (IDNA failures) from
getaddrinfo as a resolution failure, not an uncaught exception (CodeRabbit).
- authentication/adapter/base: stream the avatar download so the size cap
actually bounds memory, upload the size-bounded buffer (not response.content),
and always close the response (CodeRabbit, major).
- tests: cover auth preservation, IPv6 Host bracketing, IDNA handling, and
streamed session lifetime; drop an unused import.
docker compose test: 204 unit tests pass.
- Define API_KEY_RATE_LIMIT in plane/settings/common.py and read it via
django.conf.settings in ApiKeyRateThrottle instead of os.environ.
- Remove ServiceTokenRateThrottle and the service-token branch in
BaseAPIView.get_throttles; all API key requests now go through
ApiKeyRateThrottle.
* fix(web): add Safari fallback for requestIdleCallback
* fix(web): use globalThis in idle-task fallbacks
Switch idle-task fallback paths from window.* to globalThis.* so the
fallback no longer crashes in environments where window is undefined.
Also thread IdleRequestOptions through requestIdleFallback so the
caller's timeout hint is honored when falling back.
Addresses CodeRabbit review feedback on #9137.
---------
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
* chore: bump turbo to 2.9.14, migrate pnpm config to workspace yaml
- Bump turbo from 2.9.4 to 2.9.14 in root package.json and the
four production Dockerfiles (web, live, admin, space).
- Move pnpm.overrides, onlyBuiltDependencies, and
ignoredBuiltDependencies from package.json into pnpm-workspace.yaml.
pnpm v10+ no longer reads the pnpm field in package.json, so the
full overrides block and most of onlyBuiltDependencies were being
silently ignored.
- Add @plane/utils as a workspace dependency to the live server.
* chore: drop unused allowBuilds block, bump lodash-es to 4.18.1
- Remove the `allowBuilds` block from pnpm-workspace.yaml. It is not
a recognized pnpm v10/v11 key and its values were inconsistent with
the actual `onlyBuiltDependencies` / `ignoredBuiltDependencies`
configuration.
- Bump `lodash-es` catalog entry from 4.18.0 to 4.18.1. With overrides
now applied workspace-wide, 4.18.0 (marked deprecated as a "bad
release") was being enforced everywhere.
* fix: use pnpm v11 allowBuilds in place of removed legacy keys
`onlyBuiltDependencies` and `ignoredBuiltDependencies` were removed
in pnpm v11. They were being silently ignored on this branch, which
caused `ERR_PNPM_IGNORED_BUILDS` to fail CI under `--frozen-lockfile`.
Replace them with the v11-native `allowBuilds:` block, mapping the
previous allowlist to `true` and the previous denylist (sharp) to
`false`. Locally verified that the build scripts for @parcel/watcher,
@swc/core, esbuild, and msgpackr-extract now run on install.