Compare commits

...

27 Commits

Author SHA1 Message Date
sriram veeraghanta 6b2edbd8ee fix: move folders 2026-02-17 00:09:24 +05:30
Aaryan Khandelwal d497304de5 refactor: table drag preview using decorations (#8597)
* refactor: table drag preview using decorations

* fix: history meta for table drag state
2026-02-13 17:59:37 +05:30
Anmol Singh Bhatia 8fa08b2506 [GIT-57 | WEB-5912] fix: app sidebar ux and responsiveness (#8560)
* fix: project extended sidebar accordion ux

* fix: app sidebar mobile responsiveness ux

* chore: code refactor
2026-02-13 17:43:07 +05:30
sriram veeraghanta efc600ad8c chore(deps): update axios dependency 2026-02-13 16:35:16 +05:30
stelmsk a3a1d141cb i18n(ru): expand Russian translation coverage (#8603)
Added missing translations for:
- Profile preferences (language, timezone settings)
- Account settings sections (preferences, notifications, security, api-tokens, activity)
- Workspace settings (billing, exports, webhooks headings/descriptions)
- Project settings (states, labels, estimates, automations headings/descriptions)
- Power-K command palette (contextual actions, navigation, creation, preferences, help)
- Sidebar elements (stickies, your_work, pin/unpin)
- Common actions (copy_markdown, overview)
- Navigation customization options
2026-02-13 16:30:33 +05:30
sriram veeraghanta dfce8c6278 chore: admin folder structure (#8632)
* chore: admin folder structure

* fix: copy right check and formatting

* fix: types
2026-02-13 16:29:45 +05:30
Vamsi Krishna fab84eb058 [WEB-5899]fix: project sort order (#8530)
* fix: project sort order

* chore: updated queryset for sort_order
2026-02-13 15:52:22 +05:30
Vipin Chaudhary 60734b25ba [WIKI-877] fix: order of this dropdown options in pages #8563 2026-02-13 15:49:10 +05:30
Sangeetha cd613e5f8f [SECUR-105] fix: csv injection vulnerability sanitization #8611 2026-02-13 15:37:13 +05:30
Anmol Singh Bhatia a8d81656fc [WEB-6137] fix: work item peek view outside click #8610 2026-02-12 16:28:51 +05:30
Anmol Singh Bhatia dbe059b7b5 fix: workitem description input inital load (#8617) 2026-02-12 14:03:01 +05:30
Anmol Singh Bhatia c93f9fc865 [WEB-6038] fix: work item empty title flicker #8618 2026-02-12 13:35:08 +05:30
Akshat Jain bcc8fb4d1d style: update ASCII art in install script header (#8628) 2026-02-12 13:34:31 +05:30
dependabot[bot] b59e541b35 chore(deps): bump cryptography (#8625)
Bumps the pip group with 1 update in the /apps/api/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 44.0.1 to 46.0.5
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/44.0.1...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 13:06:42 +05:30
sriram veeraghanta 2b6e24d526 chore: merge helpers and layouts (#8624)
* fix: remove constants and services

* fix: formatting

* chore: merge helpers and layouts

* fix: workspace disbale flag handling
2026-02-10 22:04:07 +05:30
sriram veeraghanta 7793febcf8 chore: merge constants and services (#8623)
* fix: remove constants and services

* fix: formatting

* fix: types check
2026-02-10 21:10:54 +05:30
sriram veeraghanta 06e4a1624c fix: remove ee folder from web (#8622) 2026-02-10 20:01:42 +05:30
Sangeetha 57ce2a5429 [WEB-6149] migration: change estimate point key max value to 50 #8620 2026-02-10 17:07:14 +05:30
sriramveeraghanta 0887cbbda8 chore(deps): update lodash package 2026-02-09 19:26:39 +05:30
M. Palanikannan e1227f0b58 fix: type fix for description payload (#8619)
* fix: type fix

* fix: duplicate type fix
2026-02-09 15:18:09 +05:30
sriramveeraghanta ea7b30bc9c chore(deps): update the node pacakges 2026-02-09 14:59:33 +05:30
Vipin Chaudhary dfbd043e50 [WEB-6058] chore : add logic to handle save#8614 2026-02-09 14:53:35 +05:30
sriramveeraghanta 13a679437d chore(deps): upgrade django version 2026-02-09 14:51:53 +05:30
Sangeetha 78729277e8 [SECUR-104] fix: Arbitrary Modification of API Token Rate Limits#8612 2026-02-09 14:50:29 +05:30
Sangeetha d191615a5e [SECUR-113] fix: ssrf for work item links (#8607) 2026-02-05 15:03:43 +05:30
sriramveeraghanta 587cb3ecfe fix: file fomatting 2026-02-02 18:08:44 +05:30
Bavisetti Narayan b8d3b3c5eb fix: module percentage calculation (#8595) 2026-01-29 14:48:43 +05:30
415 changed files with 1020 additions and 1642 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import { Outlet } from "react-router";
// components
import { AdminHeader } from "@/components/common/header";
import { LogoSpinner } from "@/components/common/logo-spinner";
import { NewUserPopup } from "@/components/new-user-popup";
import { NewUserPopup } from "@/components/common/new-user-popup";
// hooks
import { useUser } from "@/hooks/store";
// local components
+1 -1
View File
@@ -16,7 +16,7 @@ import { Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common/banner";
// local components
import { FormHeader } from "../../../core/components/instance/form-header";
import { FormHeader } from "@/components/instance/form-header";
import { AuthBanner } from "./auth-banner";
import { AuthHeader } from "./auth-header";
import { authErrorHandler } from "./auth-helpers";
-25
View File
@@ -1,25 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { enableStaticRendering } from "mobx-react";
// stores
import { CoreRootStore } from "@/store/root.store";
enableStaticRendering(typeof window === "undefined");
export class RootStore extends CoreRootStore {
constructor() {
super();
}
hydrate(initialData: any) {
super.hydrate(initialData);
}
resetOnSignOut() {
super.resetOnSignOut();
}
}
@@ -10,7 +10,7 @@ import { Menu, Settings } from "lucide-react";
// icons
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { BreadcrumbLink } from "../breadcrumb-link";
// hooks
import { useTheme } from "@/hooks/store";
// local imports
@@ -16,8 +16,8 @@ import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
// components
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
import { Banner } from "@/components/common/banner";
import { FormHeader } from "@/components/instance/form-header";
import { Banner } from "../common/banner";
import { FormHeader } from "./form-header";
// service initialization
const authService = new AuthService();
-7
View File
@@ -1,7 +0,0 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "ce/store/root.store";
@@ -6,7 +6,7 @@
import { createContext } from "react";
// plane admin store
import { RootStore } from "@/plane-admin/store/root.store";
import { RootStore } from "../store/root.store";
let rootStore = new RootStore();
@@ -19,7 +19,7 @@ import type {
IInstanceConfig,
} from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
export interface IInstanceStore {
// issues
@@ -53,7 +53,7 @@ export class InstanceStore implements IInstanceStore {
// service
instanceService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observable
isLoading: observable.ref,
@@ -17,7 +17,7 @@ import { WorkspaceStore } from "./workspace.store";
enableStaticRendering(typeof window === "undefined");
export abstract class CoreRootStore {
export class RootStore {
theme: IThemeStore;
instance: IInstanceStore;
user: IUserStore;
@@ -6,7 +6,7 @@
import { action, observable, makeObservable } from "mobx";
// root store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "./root.store";
type TTheme = "dark" | "light";
export interface IThemeStore {
@@ -27,7 +27,7 @@ export class ThemeStore implements IThemeStore {
isSidebarCollapsed: boolean | undefined = undefined;
theme: string | undefined = undefined;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
isNewUserPopup: observable.ref,
@@ -11,7 +11,7 @@ import { EUserStatus } from "@plane/constants";
import { AuthService, UserService } from "@plane/services";
import type { IUser } from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
export interface IUserStore {
// observables
@@ -36,7 +36,7 @@ export class UserStore implements IUserStore {
userService;
authService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
isLoading: observable.ref,
@@ -10,7 +10,7 @@ import { action, observable, runInAction, makeObservable, computed } from "mobx"
import { InstanceWorkspaceService } from "@plane/services";
import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types";
// root store
import type { CoreRootStore } from "@/store/root.store";
import type { RootStore } from "@/store/root.store";
export interface IWorkspaceStore {
// observables
@@ -37,7 +37,7 @@ export class WorkspaceStore implements IWorkspaceStore {
// services
instanceWorkspaceService;
constructor(private store: CoreRootStore) {
constructor(private store: RootStore) {
makeObservable(this, {
// observables
loader: observable,
+1 -6
View File
@@ -9,12 +9,7 @@
"types": ["vite/client"],
"paths": {
"package.json": ["./package.json"],
"ce/*": ["./ce/*"],
"@/app/*": ["./app/*"],
"@/*": ["./core/*"],
"@/plane-admin/*": ["./ce/*"],
"@/ce/*": ["./ce/*"],
"@/styles/*": ["./styles/*"]
"@/*": ["./*"]
}
},
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
+3
View File
@@ -19,6 +19,9 @@ class APITokenSerializer(BaseSerializer):
"updated_at",
"workspace",
"user",
"is_active",
"last_used",
"user_type",
]
+4 -6
View File
@@ -50,11 +50,10 @@ class ProjectViewSet(BaseViewSet):
use_read_replica = True
def get_queryset(self):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
sort_order = ProjectUserProperty.objects.filter(
user=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
return self.filter_queryset(
super()
@@ -140,11 +139,10 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def list(self, request, slug):
sort_order = ProjectMember.objects.filter(
member=self.request.user,
sort_order = ProjectUserProperty.objects.filter(
user=self.request.user,
project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"),
is_active=True,
).values("sort_order")
projects = (
+10 -7
View File
@@ -49,6 +49,7 @@ from plane.bgtasks.workspace_seed_task import workspace_seed
from plane.bgtasks.event_tracking_task import track_event
from plane.utils.url import contains_url
from plane.utils.analytics_events import WORKSPACE_CREATED, WORKSPACE_DELETED
from plane.utils.csv_utils import sanitize_csv_row
class WorkSpaceViewSet(BaseViewSet):
@@ -81,12 +82,14 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value([
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
}
])
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
}
]
)
if DISABLE_WORKSPACE_CREATION == "1":
return Response(
@@ -369,7 +372,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
"""Generate CSV buffer from rows."""
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
[writer.writerow(row) for row in rows]
[writer.writerow(sanitize_csv_row(row)) for row in rows]
csv_buffer.seek(0)
return csv_buffer
+5 -5
View File
@@ -46,7 +46,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
.annotate(
completed_issues=Count(
"issue_module__issue__state__group",
"issue_module",
filter=Q(
issue_module__issue__state__group="completed",
issue_module__issue__archived_at__isnull=True,
@@ -58,7 +58,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
.annotate(
cancelled_issues=Count(
"issue_module__issue__state__group",
"issue_module",
filter=Q(
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
@@ -70,7 +70,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
.annotate(
started_issues=Count(
"issue_module__issue__state__group",
"issue_module",
filter=Q(
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
@@ -82,7 +82,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
.annotate(
unstarted_issues=Count(
"issue_module__issue__state__group",
"issue_module",
filter=Q(
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
@@ -94,7 +94,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
)
.annotate(
backlog_issues=Count(
"issue_module__issue__state__group",
"issue_module",
filter=Q(
issue_module__issue__state__group="backlog",
issue_module__issue__archived_at__isnull=True,
@@ -24,6 +24,7 @@ from plane.license.utils.instance_value import get_email_configuration
from plane.utils.analytics_plot import build_graph_plot
from plane.utils.exception_logger import log_exception
from plane.utils.issue_filters import issue_filters
from plane.utils.csv_utils import sanitize_csv_row
row_mapping = {
"state__name": "State",
@@ -180,7 +181,7 @@ def generate_csv_from_rows(rows):
"""Generate CSV buffer from rows."""
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
[writer.writerow(row) for row in rows]
[writer.writerow(sanitize_csv_row(row)) for row in rows]
return csv_buffer
+48 -15
View File
@@ -4,6 +4,7 @@
# Python imports
import logging
import socket
# Third party imports
from celery import shared_task
@@ -26,7 +27,7 @@ DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoP
def validate_url_ip(url: str) -> None:
"""
Validate that a URL doesn't point to a private/internal IP address.
Only checks if the hostname is a direct IP address.
Resolves hostnames to IPs before checking.
Args:
url: The URL to validate
@@ -38,17 +39,31 @@ def validate_url_ip(url: str) -> None:
hostname = parsed.hostname
if not hostname:
return
raise ValueError("Invalid URL: No hostname found")
# Only allow HTTP and HTTPS to prevent file://, gopher://, etc.
if parsed.scheme not in ("http", "https"):
raise ValueError("Invalid URL scheme. Only HTTP and HTTPS are allowed")
# Resolve hostname to IP addresses — this catches domain names that
# point to internal IPs (e.g. attacker.com -> 169.254.169.254)
try:
ip = ipaddress.ip_address(hostname)
except ValueError:
# Not an IP address (it's a domain name), nothing to check here
return
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError("Hostname could not be resolved")
# It IS an IP address - check if it's private/internal
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
if not addr_info:
raise ValueError("No IP addresses found for the hostname")
# Check every resolved IP against blocked ranges to prevent SSRF
for addr in addr_info:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
raise ValueError("Access to private/internal networks is not allowed")
MAX_REDIRECTS = 5
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
@@ -74,11 +89,23 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
validate_url_ip(final_url)
try:
response = requests.get(final_url, headers=headers, timeout=1)
final_url = response.url # Get the final URL after any redirects
# Manually follow redirects to validate each URL before requesting
redirect_count = 0
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
# check for redirected url also
validate_url_ip(final_url)
while response.is_redirect and redirect_count < MAX_REDIRECTS:
redirect_url = response.headers.get("Location")
if not redirect_url:
break
# Resolve relative redirects against current URL
final_url = urljoin(final_url, redirect_url)
# Validate the redirect target BEFORE making the request
validate_url_ip(final_url)
redirect_count += 1
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
if redirect_count >= MAX_REDIRECTS:
logger.warning(f"Too many redirects for URL: {url}")
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
@@ -134,7 +161,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
favicon_href = urljoin(base_url, favicon_tag["href"])
validate_url_ip(favicon_href)
return favicon_href
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
@@ -142,7 +171,9 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
validate_url_ip(fallback_url)
response = requests.head(fallback_url, timeout=2, allow_redirects=False)
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
@@ -173,6 +204,8 @@ def fetch_and_encode_favicon(
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
validate_url_ip(favicon_url)
response = requests.get(favicon_url, headers=headers, timeout=1)
# Get content type
@@ -0,0 +1,19 @@
# Generated by Django 4.2.27 on 2026-02-09 09:37
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0118_remove_workspaceuserproperties_product_tour_and_more'),
]
operations = [
migrations.AlterField(
model_name='estimatepoint',
name='key',
field=models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)]),
),
]
+1 -1
View File
@@ -38,7 +38,7 @@ class Estimate(ProjectBaseModel):
class EstimatePoint(ProjectBaseModel):
estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points")
key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)])
key = models.IntegerField(default=0, validators=[MinValueValidator(0)])
description = models.TextField(blank=True)
value = models.CharField(max_length=255)
@@ -142,7 +142,7 @@ class TestApiTokenEndpoint:
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
@@ -159,7 +159,7 @@ class TestApiTokenEndpoint:
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
@@ -178,7 +178,7 @@ class TestApiTokenEndpoint:
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
@@ -192,7 +192,7 @@ class TestApiTokenEndpoint:
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
@@ -207,7 +207,7 @@ class TestApiTokenEndpoint:
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
@@ -226,7 +226,7 @@ class TestApiTokenEndpoint:
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
@@ -242,7 +242,7 @@ class TestApiTokenEndpoint:
# Arrange
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
@@ -258,7 +258,7 @@ class TestApiTokenEndpoint:
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
@@ -282,7 +282,7 @@ class TestApiTokenEndpoint:
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
@@ -300,7 +300,7 @@ class TestApiTokenEndpoint:
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
url = reverse("api-tokens-details", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
@@ -320,7 +320,7 @@ class TestApiTokenEndpoint:
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
url = reverse("api-tokens-details", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
@@ -333,6 +333,56 @@ class TestApiTokenEndpoint:
other_token.refresh_from_db()
assert other_token.label == "Other Token"
@pytest.mark.django_db
def test_patch_cannot_modify_token(self, session_client, create_user, create_api_token_for_user):
"""Test that token value cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
original_token = create_api_token_for_user.token
update_data = {"token": "plane_api_malicious_token_value"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.token == original_token
@pytest.mark.django_db
def test_patch_cannot_modify_user_type(self, session_client, create_user, create_api_token_for_user):
"""Test that user_type cannot be modified via PATCH"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": create_api_token_for_user.pk})
update_data = {"user_type": 1}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.user_type == 0
@pytest.mark.django_db
def test_patch_cannot_modify_service_token(self, session_client, create_user):
"""Test that service tokens cannot be modified through user token endpoint"""
# Arrange
service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens-details", kwargs={"pk": service_token.pk})
update_data = {"label": "Hacked Service Token"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
service_token.refresh_from_db()
assert service_token.label == "Service Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
@@ -341,9 +391,9 @@ class TestApiTokenEndpoint:
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens-details", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
@@ -139,8 +139,6 @@ ATTRIBUTES = {
"rowspan",
"colwidth",
"background",
"hideContent",
"hidecontent",
"style",
},
"td": {
@@ -150,8 +148,6 @@ ATTRIBUTES = {
"background",
"textColor",
"textcolor",
"hideContent",
"hidecontent",
"style",
},
"tr": {"background", "textColor", "textcolor", "style"},
+26
View File
@@ -0,0 +1,26 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# CSV utility functions for safe export
# Characters that trigger formula evaluation in spreadsheet applications
_CSV_FORMULA_TRIGGERS = frozenset(("=", "+", "-", "@", "\t", "\r", "\n"))
def sanitize_csv_value(value):
"""Sanitize a value for CSV export to prevent formula injection.
Prefixes string values starting with formula-triggering characters
with a single quote so spreadsheet applications treat them as text
instead of evaluating them as formulas.
See: https://owasp.org/www-community/attacks/CSV_Injection
"""
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
return "'" + value
return value
def sanitize_csv_row(row):
"""Sanitize all values in a CSV row."""
return [sanitize_csv_value(v) for v in row]
+4 -1
View File
@@ -9,6 +9,9 @@ from typing import Any, Dict, List, Type
from openpyxl import Workbook
# Module imports
from plane.utils.csv_utils import sanitize_csv_row
class BaseFormatter:
"""Base class for export formatters."""
@@ -84,7 +87,7 @@ class CSVFormatter(BaseFormatter):
buf = io.StringIO()
writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL)
for row in data:
writer.writerow(row)
writer.writerow(sanitize_csv_row(row))
buf.seek(0)
return buf.getvalue()
+7 -2
View File
@@ -18,6 +18,10 @@ from typing import Any, Dict, List, Union
from openpyxl import Workbook, load_workbook
# Module imports
from plane.utils.csv_utils import sanitize_csv_row, sanitize_csv_value
class BaseFormatter(ABC):
@abstractmethod
def encode(self, data: List[Dict]) -> Union[str, bytes]:
@@ -128,11 +132,12 @@ class CSVFormatter(BaseFormatter):
# Write data rows in the same field order
for row in data:
writer.writerow([row.get(key, "") for key in fieldnames])
writer.writerow(sanitize_csv_row([row.get(key, "") for key in fieldnames]))
else:
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=self.delimiter)
writer.writeheader()
writer.writerows(data)
for row in data:
writer.writerow({k: sanitize_csv_value(row.get(k, "")) for k in fieldnames})
return output.getvalue()
+2 -2
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.27
Django==4.2.28
# rest framework
djangorestframework==3.15.2
# postgres
@@ -51,7 +51,7 @@ beautifulsoup4==4.12.3
# analytics
posthog==3.5.0
# crypto
cryptography==44.0.1
cryptography==46.0.5
# html validator
lxml==6.0.0
# s3
-1
View File
@@ -358,7 +358,6 @@ export const nodeRenderers: NodeRendererRegistry = {
</Text>
);
},
};
type InternalRenderContext = {
+2 -8
View File
@@ -5,17 +5,11 @@
*/
import { logger } from "@plane/logger";
import type { TPage } from "@plane/types";
import type { TDocumentPayload, TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
description_binary: string;
description_html: string;
description: object;
};
export type TUserMention = {
id: string;
display_name: string;
@@ -121,7 +115,7 @@ export abstract class PageCoreService extends APIService {
}
}
async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise<any> {
async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise<any> {
try {
const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
headers: this.getHeader(),
@@ -728,6 +728,5 @@ describe("PDF Rendering Integration", () => {
expect(text).toContain("Text after image");
});
});
});

Some files were not shown because too many files have changed in this diff Show More