Commit Graph

334 Commits

Author SHA1 Message Date
Manish Gupta
3478d4fac4 [WEB-8352] fix(security): scope SubIssuesEndpoint to the URL project (#9466)
* [WEB-8352] fix(security): scope SubIssuesEndpoint to the URL project (GHSA-gxhv-fw9x-2pg3)

SubIssuesEndpoint is guarded only by ProjectEntityPermission, which verifies
the caller belongs to the URL project_id but not that the path issue_id lives
in that project. Both handlers then resolved issues without a project scope:

- GET filtered sub-issues by parent_id + workspace__slug only, leaking the
  names/priorities/assignees/dates of another project's sub-issues (read IDOR).
- POST loaded the parent by bare pk (no workspace/project scope) and filtered
  the moved sub-issues by workspace__slug only, letting any project member
  re-parent issues from other projects/workspaces (write IDOR).

Scope the parent lookup and both sub-issue querysets to the URL project_id
(and bind the parent to the workspace), returning 404 when the parent is not
in the caller's project. Adds 5 contract tests (3 security, 2 positive
controls); fail-before verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [WEB-8352] fix: dispatch sub-issue activity only for project-scoped issues (CodeRabbit/Copilot #9466)

The DB update + response were scoped to the URL project, but the activity loop
still iterated the raw caller-supplied sub_issue_ids. A cross-project id
(excluded from the re-parent) would still fire issue_activity.delay, whose task
does an unscoped Issue.objects.get and bumps updated_at — touching a foreign
issue and creating a bogus activity row.

Dispatch from the project-scoped sub_issues (scoped_sub_issue_ids) instead.
Strengthened the test to assert the foreign issue is absent from the response
body (sub_issues / state_distribution) and that no activity is dispatched for it
(mock). Fail-before verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(security): drop advisory identifiers from code comments

Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Plane AI <noreply@plane.so>
2026-08-28 01:52:19 +05:30
Manish Gupta
5b5af0aeca [WEB-8283] fix: bind Spaces board object IDs to the anchor's project (#9442)
* [WEB-8283] fix: bind Spaces board object IDs to the anchor's project

The public Spaces board endpoints resolved the DeployBoard from the URL
anchor but trusted the caller-supplied issue_id/comment_id/intake_id
verbatim, without verifying the object belonged to that board's
project/workspace. Any authenticated user could write comments,
reactions and votes onto arbitrary issues cross-tenant, and read EXTERNAL
comments from a different project in the same workspace.

Bind every caller-supplied object id to the board's project + workspace
before writing:
- comment / issue-reaction / vote create: require the issue to exist in
  the board's project via Issue.issue_objects (excludes draft/archived/
  triage), else 404.
- comment-reaction create: require the comment to exist in the board's
  project with access="EXTERNAL", else 404.
- intake create: require the URL intake_id to match the board's intake,
  else 400.
- comment list read: scope the queryset to the board's project_id.

Also add the missing is_votes_enabled gate on vote create for parity with
comment/reaction create (pre-existing gap in the same method).

Adds contract regression tests (fail-before verified): cross-tenant
writes and the cross-project comment read now rejected, with positive
controls confirming legitimate board writes/reads still succeed.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] test: address Copilot review — cross-workspace + votes-disabled coverage

- Add cross-workspace write test (issue in a different workspace) to
  exercise the workspace_id binding, matching the advisory's cross-tenant
  impact (previously only same-workspace/different-project was covered).
- Add a regression test for the new is_votes_enabled gate on vote create
  (votes-disabled board → 400), preventing the pre-existing gap from
  reappearing.
- Clarify the section header comment: cross-tenant writes return 404 for
  issue/comment binding, 400 for the intake binding mismatch.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] refactor: extract board-scope guards into shared helpers

Address CodeRabbit review: the identical "object belongs to the board's
project+workspace" existence check was duplicated across four create()
methods (in four different ViewSets). Extract two module-level helpers —
_issue_in_board_scope and _comment_in_board_scope — so the check is a
single source of truth and cannot drift between endpoints or be forgotten
on a new one (the exact class of bug this PR fixes).

Behavior-preserving; 14 contract tests still green.

Co-authored-by: Plane AI <noreply@plane.so>

* chore(security): drop advisory identifiers from code comments

Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>

* fix(security): use the deploy board's project_id in the reaction-create activity log

CommentReactionPublicViewSet.create() logged the activity with
str(self.kwargs.get("project_id", None)) — this route's URL only ever
supplies anchor and comment_id, never project_id, so every comment
reaction created on a public board logged project_id="None", silently
corrupting the activity/audit trail. destroy() on the same viewset
already resolves the correct project_id from the deploy board; create()
now does the same.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8283] fix: apply board-scope guards to comment/reaction read, update and delete paths

The board/issue/external-comment scoping added by this PR's create() methods
was never applied to the list, update and delete paths built on the same
models. A caller could read reactions on an INTERNAL (non-public) comment
through the public reaction list, or reach a comment or reaction they
authored through a board it doesn't actually belong to via partial_update()
or destroy(), since those methods looked up objects by pk/actor only.

Bind IssueCommentPublicViewSet.partial_update()/destroy() to the board's
project, workspace, issue_id and EXTERNAL access; bind
IssueReactionPublicViewSet.destroy() to the board's project (previously only
workspace-scoped); and bind CommentReactionPublicViewSet.get_queryset()/
destroy() to EXTERNAL comments only. Add regression coverage for each gap,
plus positive controls confirming legitimate reads/writes on the board's own
objects still work.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-08-28 00:17:41 +05:30
Manish Gupta
d25b99cae2 [WEB-8110] fix: sanitize page list order_by against an allowlist (#9387)
* [WEB-8110] fix: sanitize page list order_by against an allowlist (GHSA-2v48)

PageViewSet.get_queryset passed the raw order_by query param into
.order_by(). In Django 4.2 .order_by() resolves field names at call time,
so an unknown field (e.g. order_by=password) raises FieldError → 500 DoS,
and a valid relation path (e.g. order_by=owned_by__password) enables ORM
relational traversal (GHSA-2v48-qcjw-74ch).

Add PAGE_ORDER_BY_ALLOWLIST to utils/order_queryset.py and wrap the param
with the existing sanitize_order_by() before it reaches .order_by(),
matching the issue/project/view/notification endpoints. Unknown or
malformed values fall back to the safe -created_at default.

Covers only the app project-pages residual; the 3 external-REST-API sites
in the advisory are handled by PR #9348. EE Wiki counterpart: WEB-8111.

Add contract regression tests (fail-before verified).

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8110] fix: fold sanitized order_by into a single order_by() call (review)

Address CodeRabbit + Copilot on #9387: the sanitized .order_by(user) was
immediately overridden by a later .order_by("-is_favorite", "-created_at"),
so the order_by param had no effect on the result (dead code) and cost an
extra query-build step.

Merge them into one .order_by("-is_favorite", <sanitized>, "id") — matching
the EE project-pages viewset — so favourites stay pinned first, the
allowlisted user ordering actually applies as the secondary sort, and id is
a stable pagination tiebreak. The no-param default is unchanged
(-created_at). Add a test asserting order_by=name / -name actually reorders
the results.

Co-authored-by: Plane AI <noreply@plane.so>

* chore(security): drop advisory identifiers from code comments

Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-08-28 00:16:47 +05:30
Manish Gupta
30527927d7 [WEB-8103] fix: stop leaking webhook HMAC secret_key on reads (#9382)
* [WEB-8103] fix: stop leaking webhook HMAC secret_key on reads (GHSA-83rj)

WebhookEndpoint list/retrieve/patch pass a fields= allowlist that excludes
secret_key, but DynamicBaseSerializer.__init__ discards the caller
allowlist (fields = self.expand). With WebhookSerializer using
fields="__all__" and secret_key only in read_only_fields (read-only is
still serialized), the HMAC signing secret leaked on every webhook read
(GHSA-83rj-4282-x39v; admin-only).

Rather than secret_key = CharField(write_only=True) — which would let a
client inject their own secret on create/patch and break the intended
one-time reveal — hide it by default and reveal only where intended:

- WebhookSerializer.to_representation drops secret_key unless the
  show_secret_key context flag is set (secure by default). secret_key
  stays server-generated (default=generate_token) and non-writable.
- POST create and WebhookSecretRegenerateEndpoint pass show_secret_key so
  the secret is still returned once for the caller to configure their
  receiver; list/retrieve/patch no longer emit it.

Add contract regression tests (fail-before verified). Follow-up: the
DynamicBaseSerializer.__init__ allowlist bug affects other serializers —
tracked separately.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8103] test: address review — patch network boundary + pin to_representation

Per review (@sriramveeraghanta):
- Patch the network boundary (validate_url) instead of the whole private
  _validate_webhook_url method, so the domain/schema checks still run and
  the test survives a rename of the private method.
- Add an assertion that an explicit fields=("secret_key",) request still
  hides the key, pinning to_representation as the enforcement point so a
  future DynamicBaseSerializer._filter_fields fix can't silently re-open
  the leak.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8103] docs: spell out both levels of the dead fields= allowlist

The previous comment named only DynamicBaseSerializer.__init__ discarding the
caller's fields=, which is half the root cause. _filter_fields never removes
anything either: it builds `allowed` purely to attach expansion serializers for
names not already on the serializer, then returns self.fields unfiltered
(serializers/base.py:45-119).

So the fields= kwargs in views/webhook/base.py are no-ops on two independent
levels. Documented so a future `fields = fields or self.expand` fix isn't
assumed to re-activate the allowlists for confidentiality — _filter_fields has
to be made restrictive first. The show_secret_key context flag remains the sole
enforcement point.

Addresses @sriramveeraghanta's review on #9382. 6/6 contract tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(security): drop advisory identifiers from code comments

Explanations kept unchanged; only the IDs are removed.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:16:01 +05:30
Manish Gupta
e30605d419 [WEB-7946] fix: add rate limiting to email/password sign-in and sign-up endpoints (#9335)
* fix: add rate limiting to email/password sign-in and sign-up endpoints

All four password authentication views (app sign-in, app sign-up, space
sign-in, space sign-up) extended django.views.View, so DRF's global
AnonRateThrottle never ran and the endpoints accepted unlimited credential
guesses with no friction (brute-force / credential stuffing, GHSA-349j).

Add authentication_throttle_allows(request) at the top of each post()
method — before any DB access — using the same AuthenticationThrottle
already guarding the magic-code views. On rejection the view redirects with
RATE_LIMIT_EXCEEDED, consistent with all other throttled auth endpoints.
Default limit remains 10/minute, overridable via AUTHENTICATION_RATE_LIMIT.

Co-authored-by: Plane AI <noreply@plane.so>

* refactor: consolidate auth throttle into a decorator + add tests

- Extract the repeated throttle-and-redirect block from the six redirect-flow
  auth views (email + magic, app + space) into a single throttle_auth_redirect
  decorator in rate_limit.py. Behaviour is unchanged: the throttle still runs
  before any DB access; brute-force traffic is rejected without a DB hit.
- Add regression tests for the password sign-in/sign-up throttle on both app
  and space endpoints, mirroring the existing magic-code throttle tests.
- Reset the shared AuthenticationThrottle bucket before every test in
  test_authentication.py. All auth endpoints share one per-IP throttle scope,
  so the newly-throttled password requests exhausted the budget mid-file and
  caused unrelated tests to trip RATE_LIMIT_EXCEEDED.

---------

Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2026-08-28 00:13:58 +05:30
sriram veeraghanta
e093a22094 chore: remove posthog integration and analytics scaffold (#9634)
* chore: remove posthog integration and analytics scaffold

Removes the PostHog integration end to end, plus the inert autocapture
scaffold left behind by an earlier partial removal (d61b157929,
"chore: remove posthog events (#8465)").

Backend:
- delete bgtasks/event_tracking_task.py and utils/analytics_events.py
- drop all 6 track_event.delay call sites
- drop POSTHOG_API_KEY / POSTHOG_HOST settings
- stop returning posthog_api_key / posthog_host from GET /api/instances/
- drop the posthog==3.5.0 dependency

Frontend:
- delete packages/constants/src/event-tracker (all 40 exports were unused)
- remove 42 data-ph-element attributes across 36 files
- remove the dead shouldTrackEvents and trackerElements prop chains
- remove the Microsoft Clarity session-recording tag

Note: GET /api/instances/ no longer returns posthog_api_key/posthog_host.
Nothing in this repo read them and neither do plane-ee or plane-commercial,
but the endpoint is AllowAny and cached for 2h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HD7dTidmtWWRRiFv3nmW3s

* chore: apply oxfmt formatting

Collapse JSX elements and import statements that were left multi-line
after the tracker props and specifiers were removed. Whitespace only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HD7dTidmtWWRRiFv3nmW3s

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 21:07:34 +05:30
sriram veeraghanta
e056bbf9eb chore: dump version 2026-08-16 23:36:30 +05:30
sriram veeraghanta
31853ab2b8 chore: resolve dependabot security alerts (pnpm + pip) (#9549)
Python (apps/api):
- cryptography 48.0.1 -> 50.0.0 (PKCS#7 Bleichenbacher oracle, high)

npm (pnpm-workspace.yaml catalog/overrides + lockfile):
- react-router 7.15.1 -> 7.18.1, @react-router/dev -> 7.17.0,
  @react-router/node/serve -> 7.18.1 (DoS, open redirect, XSS, constructor injection)
- sharp ^0.34.3 -> ^0.35.3 (libvips CVEs, high)
- fast-uri -> 3.1.5 via override (host confusion, high)
- js-yaml -> 4.3.0 via override (quadratic CPU DoS, high)
- linkify-it -> 5.0.2 via override (mailto validator DoS, high)
- postcss 8.5.15 -> 8.5.25 (source map path traversal, high/medium)
- undici -> 7.29.0 via override (info disclosure, CRLF/cookie injection)
- sanitize-html 2.17.0 -> 2.17.5 (URI scheme validation bypass, medium)
- valibot -> 1.4.2 via override (flatten() throw, medium)
- body-parser -> 1.20.6 via override (limit bypass DoS, low)
2026-08-05 00:48:59 +05:30
sriramveeraghanta
ed61f9925b chore: update package version 2026-08-04 20:11:48 +05:30
sriram veeraghanta
25c6843fce fix: enforce FILE_SIZE_LIMIT on published Space asset upload (#9242)
* fix(api): enforce FILE_SIZE_LIMIT on published Space asset upload

The public Space asset upload endpoint
(POST /api/public/assets/v2/anchor/{anchor}/) trusted the client-supplied
`size` value end-to-end: it was stored on the FileAsset and passed straight
to generate_presigned_post(), which uses it as the S3/MinIO policy bound
(["content-length-range", 1, file_size]). This let an authenticated user
obtain a signed upload policy exceeding the instance's FILE_SIZE_LIMIT.

Cap the value with `size_limit = min(size, settings.FILE_SIZE_LIMIT)` and use
it consistently for the stored asset metadata and the presigned POST policy,
matching every other asset upload endpoint.

* fix(api): clamp Space asset size to a valid lower bound

Address review feedback: reject malformed (non-integer) `size` with 400 and
clamp the value to [1, FILE_SIZE_LIMIT] via max(1, min(...)) so the presigned
content-length-range is always valid and no non-positive size is persisted.
2026-08-04 20:10:03 +05:30
Atul Tameshwari
a18177ce5e feat(api): enhance workspace module query to include member IDs (#9541)
Added an annotation to the WorkspaceModulesEndpoint to aggregate member IDs into an array, ensuring that only active members are included. This change improves the data structure returned by the API, allowing for better handling of member information in the frontend. Updated the corresponding utility function to handle potential null values for member IDs.
2026-08-04 19:50:08 +05:30
Nikhil
1ed664e8f8 [WEB-8512] feat: add workspace member reactivation command (#9520)
* feat: add command to reactivate workspace members with error handling

* fix: address review comments on reactivate command

- normalize email input to match User.save lowercasing
- fix grammar in error messages
- limit save to is_active so audit fields are not clobbered

Claude-Session: https://claude.ai/code/session_01NGjXVUi4D8JGWy7b7KDNaN

* fix: normalize inputs before validation and report partial reactivation

- strip slug/email before the required checks so whitespace-only args are rejected
- bump updated_at and pass disable_auto_set_user so the audit fields survive
- report the restored role, inactive project memberships, and inactive accounts

Claude-Session: https://claude.ai/code/session_01NGjXVUi4D8JGWy7b7KDNaN

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2026-08-02 03:17:23 +05:30
Manish Gupta
39856932cd [WEB-8477] fix(api): filter "Updated At" by updated_at column, not created_at (#9514)
filter_updated_at() passed `created_at__date` as the date term in both its GET
and POST branches, so filtering by "Updated At" actually filtered on the
creation date. Work items updated today but created earlier never appeared under
"Updated At -> is -> today", and the two filters returned identical result sets.

Re-raise of community PR #9323 by @sanjibani, which is approved-ready but cannot
merge because the CLA is unsigned. Original patch and tests carried over
unchanged apart from the two fixes below; credit for the fix is theirs.

Adjustments made while porting:
- test_get_method_targets_updated_at_column asserted the exact key
  "updated_at__date", but date_filter's single-value branch appends a lookup
  suffix ("updated_at__date__contains"), so the assertion always failed. Match
  on the key prefix instead.
- Added the missing trailing newline to the new test file.

Verified on a local canary build: with one work item created 2020-01-01 but
updated today, `?updated_at=2026-07-01;after` now returns 5 while
`?created_at=2026-07-01;after` returns 4 — previously both returned 4. Unit
tests pass (5); 4 of the 5 fail without the source change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 20:30:21 +05:30
Satya Bharadwaj
027a5a0330 fix: cast avatar_asset to CharField to resolve mixed type errors in URL concatenation (#9512) 2026-07-30 20:29:41 +05:30
Manish Gupta
e496b24f27 [WEB-8477] fix: created_at/updated_at filters return no work items (#9513)
Bug 5: filtering the work item list by a creation/update date returned an empty
list. created_at and updated_at are DateTimeFields, but the UI sends a bare
calendar date, and the filterset only exposed `exact` and `range` lookups:

- {"created_at__exact": "2026-07-30"} was coerced to 2026-07-30 00:00:00, so it
  matched only rows stamped exactly midnight — effectively never.
- {"created_at__range": "2026-07-28,2026-07-30"} capped the upper bound at
  2026-07-30 00:00:00, silently dropping everything created during that final
  day (the range only "worked" if you overshot the end date by one day).

Compare the date component instead (`date` / `date__range` via a CSV-parsing
DateCSVRangeFilter), so a calendar date means the whole day and both range
bounds are inclusive. The UI's existing query format is unchanged.

Verified against a local canary build: the exact requests from the bug report
now return 4 and 4 (previously 0 and 0). Adds unit coverage; 6 of the 7 new
tests fail without this change.

Note: `__date` is evaluated in the active timezone, which TimezoneMixin takes
from the user's profile (user_timezone) rather than the browser's timezone, so a
profile/browser timezone mismatch can still shift results by a day. Tracked
separately — not addressed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 19:51:16 +05:30
sriram veeraghanta
08a7d12b9d fix: resolve open CodeQL security alerts (#9505)
- adapter/oauth.py: stop logging request headers on user-info fetch
  failure; they carry the Bearer access token (py/clear-text-logging-sensitive-data)
- adapter/base.py: drop the email value from the invalid-email warning
  log (py/clear-text-logging-sensitive-data)
- provider/oauth/github.py: stop logging organization id / user login
  on org-membership failure (py/clear-text-logging-sensitive-data)
- editor custom-link: rewrite the C0-control strip regex with escaped,
  non-overlapping ranges instead of raw control bytes overlapping \s
  (js/overly-large-range); also fixes the file being detected as binary
2026-07-29 20:28:09 +05:30
sriram veeraghanta
ca3b48ef87 chore: upgrade Django 4.2 → 5.2 (#9325)
* chore(api): upgrade Django 4.2 → 5.2 and bump Django ecosystem deps

Upgrade Django 4.2.30 LTS → 5.2.15 LTS and bump all Django-coupled
dependencies to versions that officially support 5.2 (DRF 3.17.1,
channels 4.3.2, django-cors-headers 4.9.0, django-filter 25.2,
django-storages 1.14.6, django-redis 7.0.0, celery 5.5.3,
django-celery-beat 2.9.0, django-celery-results 2.6.0,
drf-spectacular 0.29.0, scout-apm 3.5.3, psycopg 3.3.4,
whitenoise 6.12.0, django-debug-toolbar 6.0.0, pytest-django 4.12.0).
OpenTelemetry set, django-crum and pytz held (already 5.2-compatible).

Code changes the upgrade required:
- urls.py: gate the debug-toolbar URL include on apps.is_installed(),
  since django-debug-toolbar 6.0 ships a model that errors when the app
  isn't in INSTALLED_APPS (test settings run DEBUG=True but don't install it).
- migration 0122: state-only AlterField for three M2M fields using
  through_fields (Django 5.1 deconstruction normalization); sqlmigrate is a
  no-op, zero DB impact.
- test_authentication.py: module-level autouse cache.clear() fixture to fix
  8 pre-existing throttle test-isolation failures (identical on the 4.2
  baseline) so the suite is green.

Verified on python:3.12-alpine + Postgres 15.7: check clean,
makemigrations --check clean, full migrate applies, pytest 393 passed.

Adds the migration plan/audit write-up under apps/api/docs/.

* fix(api): scope auth test cache reset to throttle keys only

The module-wide _reset_auth_throttle_cache fixture called cache.clear(),
wiping the entire shared Redis cache between tests. Replace it with
targeted deletion of throttle_authentication_* keys (DRF
SimpleRateThrottle history for AuthenticationThrottle) via
cache.delete_pattern, keeping the same before/after cleanup.
2026-07-29 19:58:33 +05:30
Sangeetha
4a671ac797 [GIT-243]fix: InstanceConfiguration not created for some keys (#9303) 2026-07-29 19:33:02 +05:30
Manish Gupta
15e835710c [SECUR-242] fix(api): scope bulk-asset associate by uploader, not project_id (regression from #9288) (#9495)
* [SECUR-242] fix(api): scope bulk-asset associate by uploader, not project_id

Regression from #9288 (WEB-7776, cross-project IDOR scoping): adding
project_id=project_id to ProjectBulkAssetEndpoint.post broke project creation —
the "enable features" step 404s because the freshly-uploaded cover/feature asset
still has project_id=NULL (this endpoint is what sets it). master had no such
filter.

Scope the lookup by created_by=request.user instead. This still closes the IDOR
(#9288) — a caller can only touch assets they uploaded, and @allow_permission
already scopes them to the project — and is stricter than the original master
code (which had no ownership check), while allowing not-yet-associated assets to
be linked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [SECUR-242] fix: bound bulk-asset associate to unassociated-or-same-project (CodeRabbit)

Address CodeRabbit: created_by alone let a user move their own asset from another
project into this one via the PROJECT_COVER/ISSUE_DESCRIPTION update branches.
Add an unassociated-or-same-project bound (project_id=project_id OR project_id IS
NULL) alongside created_by, so freshly-uploaded (NULL) assets still link but
cross-project moves are rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 12:16:05 +05:30
Karthikeyan Ganesh
49c4da6d4b fix: strip control characters from sanitized filenames (#9151)
Prevent tab, newline, and other ASCII control characters from appearing
in S3 object keys generated from user-provided upload filenames.

Fixes #9127

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 17:52:46 +05:30
sriramveeraghanta
6a061acc69 chore: pacakge version bump 2026-07-28 15:50:53 +05:30
Akhil Vamshi Konam
7cef741c29 feat(api): add lite list endpoints for projects, members, cycles, and modules (#9410)
* feat: add lite list endpoints for projects, members, cycles, and modules

* refactor: enhance order_by sanitization for cycle and module endpoints, update error handling for non-existent projects and workspaces
2026-07-17 17:40:26 +05:30
Manish Gupta
af1be50b48 [WEB-8074] fix: scope IssueListEndpoint to guest created_by (#9374)
* [WEB-8074] fix: scope IssueListEndpoint to guest created_by

IssueListEndpoint.get (/workspaces/<slug>/projects/<project_id>/issues/list/)
returned any issue whose id was passed in ?issues=, without the guest
created_by restriction its sibling IssueViewSet.list enforces. A project GUEST
(role=5) on a project with guest_view_all_features=False could read issues they
did not author by supplying their ids (GHSA-32c7-84jc-4w67).

Replicate the guest scope: when the requester is an active role=5 ProjectMember
and not project.guest_view_all_features, filter the queryset to
created_by=request.user. Applied to the base queryset so it flows through
filtering, annotation and grouping.

Contract regression tests cover the restricted guest (own-only), a full member
(sees all), and a guest with guest_view_all_features enabled (sees all);
fail-before verified.

Co-authored-by: Plane AI <noreply@plane.so>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Plane AI <noreply@plane.so>
Co-authored-by: Dheeraj Kumar Ketireddy <dheeraj.ketireddy@plane.so>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-16 16:03:25 +05:30
Manish Gupta
cfe951c8af [WEB-8095] fix: scope page-version reads to the URL project (GHSA-g49r/ghcr) (#9380)
* [WEB-8095] fix: scope page-version reads to the URL project (GHSA-g49r/ghcr)

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>

* [WEB-8095] fix: require active ProjectPage link when scoping pages to a project

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>

* [WEB-8095] fix: distinct() on page-version detail lookup as a MultipleObjectsReturned guard

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>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 16:03:06 +05:30
Manish Gupta
5842ca8bf2 [WEB-8075] fix: scope ProjectMemberPermission SAFE_METHODS to project membership (#9375)
The SAFE_METHODS branch of ProjectMemberPermission filtered ProjectMember by
workspace only (no project_id), so any workspace user who was a member of *some*
project could pass the check for a project they were not in. Consumers then
returned project-scoped data:
  - v1 ProjectMemberListCreateAPIEndpoint.get -> full project roster
    (GHSA-w2vf-m9x9-mvmc)
  - app DeployBoardViewSet.list -> project publish configuration (identical
    app-copy sibling)

Add project_id=view.project_id to the SAFE_METHODS filter in both copies
(utils + app), mirroring the non-safe branch and ProjectEntityPermission. A
non-member now receives 403.

Contract regression tests cover both endpoints: a workspace user who is a
member of a different project is denied (403) on a foreign project, while an
active member of the target project is allowed. Fail-before verified (both
denied cases leak 200 without the fix).

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 16:01:52 +05:30
Manish Gupta
b3591b9e63 [WEB-8068] fix: scope workspace cycles/modules listing to project membership (#9373)
* [WEB-8068] fix: scope workspace cycles/modules listing to project membership

WorkspaceCyclesEndpoint and WorkspaceModulesEndpoint are guarded only by
WorkspaceViewerPermission (any active workspace member) and filtered by
workspace__slug alone, letting any workspace member enumerate cycle/module
metadata (names, dates, issue counts) of private projects they are not a
member of (GHSA-wcc5-qgfr-8g9c).

Restrict both querysets to projects the requesting user is an active member
of, mirroring WorkspaceStatesEndpoint / WorkspaceLabelsEndpoint:
  project__project_projectmember__member=request.user
  project__project_projectmember__is_active=True
  project__archived_at__isnull=True
Add .distinct() to the Module query (the member join is to-many; Cycle already
had it).

Contract regression tests cover hidden cycles/modules for a non-project member,
the positive project-member path, and no row duplication; fail-before verified.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8068] refactor: drop unnecessary distinct() from module listing

Address Copilot review: the project-membership join is filtered to
request.user, and ProjectMember has a unique constraint on (project, member)
where deleted_at IS NULL, so the join yields at most one row per project and
cannot duplicate Module rows. distinct() was dead weight (and a planner cost
for large workspaces). Matches the reference WorkspaceStates/WorkspaceLabels
endpoints, which use no distinct().

Also drop the distinct-focused contract test: adding a *different* project
member never fans out the request.user-filtered join, so it would pass with or
without distinct() — misleading coverage.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 16:00:08 +05:30
Manish Gupta
e63f0c3b34 [WEB-8066] fix: scope workspace asset get/patch/delete to project membership (#9372)
* [WEB-8066] fix: scope workspace asset get/patch/delete to project membership

WorkspaceFileAssetEndpoint is authorized at the WORKSPACE level, so any
workspace member/guest could reach get/patch/delete for a project-bound
asset (issue attachment/description, comment description, page description)
of a project they are not a member of — an incomplete fix of the GHSA-qw87
asset-IDOR cluster (GHSA-h7mc-p9mm-2r4w / GHSA-cjph-cgm5-8pw8).

Add project_membership_denied(): for project-bound assets (project_id set)
require an active ProjectMember of the asset's project, else 403. Workspace-
level entity types (WORKSPACE_LOGO, USER_AVATAR, USER_COVER) have project_id
NULL and remain accessible to any workspace member. Mirrors ProjectAssetEndpoint
(level=PROJECT). Guard runs before the is_uploaded check / mutation so a
non-member gets a uniform 403 and cannot probe upload state.

Contract regression tests cover denied get/patch/delete for a non-project
member, the positive project-member path, and the workspace-level exemption;
fail-before verified.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8066] harden: scope asset project-membership check to the asset's workspace

Address Copilot review: filter ProjectMember by workspace_id=asset.workspace_id
in addition to project_id, mirroring allow_permission's PROJECT branch. Prevents
a member of the same project in a different workspace from passing the check if
an asset row is ever inconsistent (asset.workspace_id != project.workspace_id).

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8066] refactor: return bool from asset access helper, build Response in views

Address review (Saurabhkmr98): rename project_membership_denied ->
has_project_asset_access, returning a boolean (True = allowed) instead of a
Response. Each of get/patch/delete now builds the 403 Response based on the
returned value. Behaviour is unchanged (same 403 + message; workspace-level
assets with project_id=None still allowed).

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-14 20:11:32 +05:30
Manish Gupta
d3d3de44cf [WEB-8012] fix: prevent ORM group_by/sub_group_by injection in issue endpoints (#9347)
Add ISSUE_GROUP_BY_ALLOWLIST and validate group_by_field_name/
sub_group_by_field_name in BasePaginator.paginate() — the single chokepoint
all GroupedOffsetPaginator/SubGroupedOffsetPaginator callers funnel through
(the unauthenticated public deploy-board endpoint plus 5 GUEST-reachable
authenticated endpoints). Invalid fields now raise ParseError (HTTP 400)
instead of reaching F()/.values()/.order_by()/Window partition_by as a raw
ORM field name, which previously let an anonymous caller crash the endpoint
or force a blind relational-traversal oracle (GHSA-wwgj-929g-42cm).

Same field-name-injection class as the order_by fix (GHSA-2r95/GHSA-w45q,
WEB-7813), which never extended to group_by/sub_group_by.

Closes WEB-8012

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-13 20:40:01 +05:30
Manish Gupta
9dff20e048 [WEB-7887] fix(security): prevent stored XSS via SVG attachment served inline (GHSA-ch8j-vr4r-qf6h) (#9312)
* [WEB-7887] fix(security): prevent stored XSS via SVG attachment served inline (GHSA-ch8j-vr4r-qf6h)

Add SCRIPT_CAPABLE_MIME_TYPES frozenset (image/svg+xml, text/javascript,
application/javascript, text/html, application/xhtml+xml, text/xml,
application/xml) and enforce Content-Disposition: attachment on three
download endpoints that previously defaulted to inline serving:

- GenericAssetEndpoint.get (api/views/asset.py)
- StaticFileAssetEndpoint.get (app/views/asset/v2.py)
- EntityAssetEndpoint.get (space/views/asset.py)

ATTACHMENT_MIME_TYPES is unchanged — users can still upload SVG, JS, and
XML files. The fix closes the XSS vector by ensuring script-capable assets
are always downloaded rather than rendered in the application's origin.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-7887] fix: normalize MIME type before SCRIPT_CAPABLE_MIME_TYPES check

Strip MIME parameters and lowercase before the allowlist check so that
stored values like "image/svg+xml; charset=utf-8" or "Image/SVG+XML"
are correctly identified as script-capable and served as attachment.
Applies to all three download endpoints.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-13 20:33:46 +05:30
Manish Gupta
dc9d80b2d2 [WEB-8060] fix(security): enforce authz on is_active member (de)activation (#9367)
* [WEB-8060] fix(security): enforce authz on is_active member (de)activation

ProjectMemberViewSet.partial_update nested every authorization guard inside
`if "role" in request.data:`. Because ProjectMemberSerializer exposes is_active
through fields="__all__" with no read-only, a project GUEST could PATCH
{"is_active": false} while omitting "role" to deactivate any member — including
admins — and take over the project (GHSA-hpgm-9r34-c4x5 / GHSA-25gg-cxm8-g7h9).

Add an independent is_active guard, mirroring the role block and destroy(): only
a project admin (or workspace admin) may (de)activate a member, and never one
whose role is equal to or higher than the requester's own.

Adds contract regression tests covering guest/member deactivation attempts and
the legitimate project-admin path.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8060] test(security): cover workspace-admin is_active bypass

Address Copilot review on #9367: add a positive-control test asserting a
workspace admin holding only a project GUEST role can still deactivate a project
admin (the intended is_workspace_admin bypass), so future changes cannot silently
remove it.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 18:37:39 +05:30
Manish Gupta
2e007e138b [WEB-8019] fix(security): scope CycleIssue reassignment lookup to workspace/project (#9349)
* [WEB-8017] fix(security): sanitize order_by on external REST API list endpoints

Close a partial bypass of WEB-7813 (GHSA-2r95 / GHSA-w45q): the external
REST API project-list and work-item-list endpoints passed a raw order_by
query parameter to Django's .order_by(). Because Django resolves
__-separated relational paths, an attacker could order by sensitive
columns on related tables (created_by__password / token / email) to build
a blind ordering oracle, or crash the endpoint (HTTP 500) with an unknown
field.

Route both endpoints through the existing sanitize_order_by() helper with
the appropriate allowlist (PROJECT_ORDER_BY_ALLOWLIST, default sort_order;
ISSUE_ORDER_BY_ALLOWLIST, default -created_at), mirroring how
order_issue_queryset() already sanitizes. Non-allowlisted values collapse
to the safe default; legitimate orderings are unchanged.

Adds unit tests (allowlist neutralisation + passthrough) and contract
tests asserting both endpoints return 200 (not 500) for injected fields;
fail-before verified via git stash.

Advisory: GHSA-p885-6jpg-cr2p

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8019] fix(security): scope CycleIssue reassignment lookup to workspace/project

CycleIssueViewSet.create looked up "issues already in another cycle" with
CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) —
without scoping to the caller's workspace/project. An ADMIN/MEMBER of their
own project could pass a work-item UUID from a different tenant and have that
foreign CycleIssue row reassigned to their cycle, silently evicting the
victim's work item from the victim's cycle (cross-tenant write / BOLA).

Scope the lookup to workspace__slug + project_id, mirroring the adjacent
create-path guard. Foreign-tenant rows are excluded from reassignment and
already dropped from the create path by the scoped new_issues query.

Adds a contract regression test proving a foreign-tenant CycleIssue row is
not reassigned (fail-before verified via git stash) plus a same-project
reassignment test to confirm the legitimate flow is unaffected.

Advisory: GHSA-4w5x-wc9w-f47x

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 18:33:48 +05:30
Manish Gupta
6395e1d39a [WEB-8017] fix(security): sanitize order_by on external REST API list endpoints (#9348)
Close a partial bypass of WEB-7813 (GHSA-2r95 / GHSA-w45q): the external
REST API project-list and work-item-list endpoints passed a raw order_by
query parameter to Django's .order_by(). Because Django resolves
__-separated relational paths, an attacker could order by sensitive
columns on related tables (created_by__password / token / email) to build
a blind ordering oracle, or crash the endpoint (HTTP 500) with an unknown
field.

Route both endpoints through the existing sanitize_order_by() helper with
the appropriate allowlist (PROJECT_ORDER_BY_ALLOWLIST, default sort_order;
ISSUE_ORDER_BY_ALLOWLIST, default -created_at), mirroring how
order_issue_queryset() already sanitizes. Non-allowlisted values collapse
to the safe default; legitimate orderings are unchanged.

Adds unit tests (allowlist neutralisation + passthrough) and contract
tests asserting both endpoints return 200 (not 500) for injected fields;
fail-before verified via git stash.

Advisory: GHSA-p885-6jpg-cr2p

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 18:32:53 +05:30
Manish Gupta
e1ef42023a [WEB-7895] fix: scope UserProjectInvitationsViewset to workspace-validated project IDs (GHSA-45hc-q4mw-jhxm) (#9333)
The `create` handler validated the network (SECRET/PUBLIC) check against
a workspace-scoped queryset but then used the raw client-supplied
`project_ids` list in the subsequent bulk_create and update calls.
An attacker could include UUIDs of projects from other workspaces: those
are absent from the validation queryset (no network check performed),
yet get inserted as ProjectMember rows via bulk_create(ignore_conflicts=True),
granting cross-workspace project access.

Fix: derive `validated_project_ids` from the filtered queryset (projects
already scoped to the requested workspace and passed the SECRET check),
and use it exclusively for all subsequent DB writes.

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 18:32:22 +05:30
Manish Gupta
14a4c22f94 [WEB-7877] fix(security): enforce token + auth validation on project invite accept/reject (#9308)
* fix(security): enforce token + auth validation on project invite accept/reject

ProjectJoinEndpoint.post() only checked that the caller-supplied email matched
the invited email — no token required, no authentication required.  Anyone who
knew the workspace slug, project ID, invite UUID, and invitee email could
accept or reject the invitation on the invitee's behalf (GHSA-g36h-p63v-g9c7).

Mirror WorkspaceJoinEndpoint.post() exactly:
- Validate `token` from request body against project_invite.token (→ 403 on mismatch)
- Require authenticated session (→ 401 if unauthenticated)
- Validate request.user.email against project_invite.email (→ 403 on mismatch)
- Remove the old request.data["email"] guard
- Use project_invite.email for downstream User lookup

Co-authored-by: Plane AI <noreply@plane.so>

* fix(security): address CR review on project invite token validation

- Use request.user directly instead of re-querying User by exact
  project_invite.email — avoids case-variant miss after the case-insensitive
  email check already validated the authenticated user (CR comment 1)
- Validate `accepted` as a real boolean before saving — form-encoded
  strings like "false" are truthy and could accidentally create memberships
  (CR comment 2)

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 18:28:59 +05:30
Manish Gupta
b91b61c379 [WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO (Cluster E) (#9289)
* [WEB-7778] fix(security): reject unverified OAuth provider emails to prevent ATO

An attacker controlling a self-hosted OAuth provider (Gitea, GitLab) could
assert any email address in the OAuth response and be matched to an existing
Plane account, bypassing authentication entirely.

- Add OAUTH_PROVIDER_UNVERIFIED_EMAIL (5124) error code
- GitHub: require both primary=True AND verified=True on email (was primary-only)
- Google: check verified_email=False field in userinfo response
- GitLab: check confirmed_at is non-null before accepting email
- Gitea __get_email: remove unverified fallbacks (primary-unverified, any-unverified)
- Gitea set_user_data: remove fast-path using .email from user object (no
  verification flag); always go through __get_email() which enforces verified

Fixes GHSA-7j95-vh8g-f365 (critical ATO).
Note: GHSA-cv9p-325g-wmv5 and GHSA-hx79-5pj5-qh42 (avatar SSRF) were
already fixed in PR #9163.

Co-authored-by: Plane AI <noreply@plane.so>

* fix(security): add read:user scope to Gitea; fail-closed on absent Google verified_email

Gitea's /api/v1/user/emails endpoint requires the read:user granular
scope — openid+email+profile alone is insufficient and __get_email()
would return a 401/403. Add read:user to the scope string.

Google: change default from True to fail-closed (is not True) so a
userinfo response that omits verified_email is rejected rather than
trusted. The service-account justification was incorrect — service
accounts do not go through the interactive OAuth2 callback flow.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-09 16:31:33 +05:30
sriram veeraghanta
4fc79a2d7e fix(security): block bot user logins (#9368)
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.
2026-07-08 01:51:37 +05:30
Ivan Kuznetsov
d5dda5d41c fix: Issues created or updated via REST API send no notifications or emails (#9307)
* fix: send notifications when work items are created or updated via the REST API

* test: rename contract test file to test_issue_notifications.py
2026-07-08 01:51:15 +05:30
Manish Gupta
7fbf14a6cb [WEB-7894] fix: eliminate TOCTOU race in InstanceAdminSignUp (GHSA-p548-28jp-wr4p) (#9332)
* [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>
2026-07-01 17:44:08 +05:30
Manish Gupta
5829f0febf [WEB-7892] fix(security): scope attachment PATCH/DELETE/GET by issue_id, drop created_by overwrite (GHSA-5mxw-g5mw-3v3w) (#9315)
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>
2026-07-01 17:41:08 +05:30
Manish Gupta
4b52dce76e [WEB-7855] fix(security): prevent project invite email disclosure via unauthenticated GET (#9305)
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>
2026-07-01 17:34:45 +05:30
Manish Gupta
28ae25b564 [WEB-7847] fix: enforce workspace membership on entity-search endpoint (#9296)
* 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>
2026-06-30 18:35:10 +05:30
Manish Gupta
4577dc3f7a [WEB-7776] fix(security): scope FileAsset queries to prevent cross-project IDOR (Cluster F) (#9288)
* [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>
2026-06-30 18:26:59 +05:30
Manish Gupta
1e8f3630c7 [WEB-7787] fix(auth): restore activation flow and narrow deactivation guard (#9304)
* 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>
2026-06-24 14:40:24 +05:30
Manish Gupta
6c9dbb5043 [WEB-7787] fix(security): block deactivated user login and fix WorkspaceOwnerPermission (#9290)
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>
2026-06-23 18:10:40 +05:30
Manish Gupta
cc3eb974f1 [WEB-7813] fix: prevent ORM order_by injection in issue and other endpoints (#9292)
* 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>
2026-06-23 18:02:52 +05:30
Manish Gupta
1acc69e816 [WEB-7805] fix: remove hardcoded SECRET_KEY from community deployment manifests (#9291)
* 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>
2026-06-23 17:59:19 +05:30
Manish Gupta
971c2aadb4 [WEB-7769] fix(security): scope EstimatePoint create/destroy to workspace and project (#9286)
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>
2026-06-23 17:52:41 +05:30
Manish Gupta
0d58adb69d [WEB-7774] fix(security): sanitize comment_html and intake description_html with nh3 (#9287)
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>
2026-06-23 17:52:31 +05:30
Manish Gupta
6220ba990b [WEB-7854] fix: prevent workspace invite token disclosure and invite hijack (#9297)
- 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>
2026-06-23 17:47:16 +05:30
sriram veeraghanta
4a0746b45e fix: scope workspace user preference filter to current user (#9279)
* 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>
2026-06-21 03:31:05 +05:30