* [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.
* fix: harden API token handling against rate-limit tampering and plaintext logging
- Make `allowed_rate_limit` read-only on APITokenSerializer so users can no
longer raise their own API token rate limit via PATCH (GHSA-xfgr-2x3f-g2cf).
- Stop persisting API keys in plaintext in APITokenLogMiddleware: store a
SHA-256 hash as the token identifier and redact sensitive request headers
(X-Api-Key, Authorization, Cookie) before logging (GHSA-r5p8-cj3q-38cc).
* refactor: remove MongoDB log sink and add per-log-type retention
Logs are now written to and cleared from PostgreSQL only; MongoDB is no
longer used as a log sink or archive.
- Drop the MongoDB write/archival paths from the API request logger, the
webhook log writer, and the cleanup tasks; Postgres is the sole sink.
- Cleanup tasks now hard-delete expired rows in batches via `all_objects`
(rows are removed immediately, not soft-deleted).
- Add env-backed, per-log-type retention settings: API activity logs
(API_ACTIVITY_LOG_RETENTION_DAYS, default 14), webhook logs
(WEBHOOK_LOG_RETENTION_DAYS, default 14), email logs
(EMAIL_LOG_RETENTION_DAYS, default 7). HARD_DELETE_AFTER_DAYS no longer
drives any log cleanup.
- Delete settings/mongo.py, remove MONGO_DB_* settings and the plane.mongo
loggers, and drop the pymongo dependency.
* chore: gitignore local advisories.md notes file
* fix: use keyed HMAC-SHA256 for API token log identifier
Address CodeQL "weak hashing of sensitive data" by hashing the API key with
a SECRET_KEY-keyed HMAC instead of a bare SHA-256. The identifier is a
non-reversible tokenization of a high-entropy key (not password storage);
keying it also prevents precomputing the digest from a known key value.
* chore: address review feedback on log cleanup and request logging
- process_logs accepts extra kwargs so jobs enqueued by an older release
(with a mongo_log arg) don't fail during a rolling deploy.
- Log-cleanup batch delete failures are logged and skipped rather than
aborting the run, so a single bad batch can't block the rest.
- Extend logger middleware test to assert Authorization and Cookie headers
are redacted; add a test that a failing cleanup batch is swallowed.
* fix: fall back to default when a log retention env value is invalid
Negative (or unparseable) retention values would compute a future cutoff and
delete every log row. The retention settings now fall back to their defaults
in that case via a shared `_retention_days` helper.
- Replace flat pr-description.md / release-notes.md with per-skill folders
- Add new branch-name and translate skills
- Update release-notes skill to match the GitHub Releases format (v1.2.0)
* chore(api): add docker compose test runner
Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.
Prereq: ./setup.sh (generates apps/api/.env).
Usage:
docker compose -f docker-compose-test.yml up --build \
--abort-on-container-exit --exit-code-from api-tests
docker compose -f docker-compose-test.yml down -v
* fix(api): correct bugs surfaced by the pytest suite
Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:
- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
required project_id in the request body, but the view only ever passes
it through the URL kwarg. Cycle create/update via the public API was
returning 400 "Project ID is required". Read project_id from
serializer context (passed by the view) in addition to body/instance.
- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
out is_service=True tokens, so a user could read and modify service
tokens through the user token endpoint. The list mode and delete
already filter is_service=False; aligned the other two.
- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
scheme, so file:///etc/passwd raised "No hostname found" instead of
the documented "Only HTTP and HTTPS" error. Swapped the order so the
scheme guard matches the docstring intent.
- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
so when both are configured to different hosts (the standard local
setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
allow-list. Redirects to APP_BASE_URL then had their next_path stripped
because the host wasn't allowed. Include every configured base URL.
* chore(api): align pytest tests with current behavior, clear warnings
Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.
Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
the Project model defaults the flag to False, so cycle create/update
always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
(validate_next_path rejects values without a leading slash and returns
empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
now returns description_json; the task unconditionally writes the value
back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
char 970+ on a single line, which contains_url truncates away as ReDoS
defense (500-char per-line cap). Restructured to keep test intent intact
while staying inside the per-line window.
Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
moved jsonlogger → json; update the import / formatter path.