release: v1.2.2 #8645

This commit is contained in:
sriram veeraghanta
2026-02-23 14:14:21 +05:30
committed by GitHub
29 changed files with 183 additions and 69 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"

View File

@@ -15,6 +15,9 @@ class APITokenSerializer(BaseSerializer):
"updated_at",
"workspace",
"user",
"is_active",
"last_used",
"user_type",
]

View File

@@ -603,7 +603,7 @@ class ProjectAssetEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def patch(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(id=pk)
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata

View File

@@ -56,7 +56,14 @@ class IssueAttachmentEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk)
issue_attachment = FileAsset.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
).first()
if not issue_attachment:
return Response(
{"error": "Issue attachment not found."},
status=status.HTTP_404_NOT_FOUND,
)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(

View File

@@ -1,6 +1,10 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Python imports
import logging
import socket
# Third party imports
from celery import shared_task
@@ -20,6 +24,48 @@ logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
def validate_url_ip(url: str) -> None:
"""
Validate that a URL doesn't point to a private/internal IP address.
Resolves hostnames to IPs before checking.
Args:
url: The URL to validate
Raises:
ValueError: If the URL points to a private/internal IP
"""
parsed = urlparse(url)
hostname = parsed.hostname
if not hostname:
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:
addr_info = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise ValueError("Hostname could not be resolved")
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]:
"""
Crawls a URL to extract the title and favicon.
@@ -31,17 +77,6 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
@@ -49,9 +84,28 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
soup = None
title = None
final_url = url
validate_url_ip(final_url)
try:
response = requests.get(url, headers=headers, timeout=1)
# Manually follow redirects to validate each URL before requesting
redirect_count = 0
response = requests.get(final_url, headers=headers, timeout=1, allow_redirects=False)
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")
@@ -60,8 +114,8 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Fetch and encode favicon using final URL (after redirects)
favicon_base64 = fetch_and_encode_favicon(headers, soup, final_url)
# Prepare result
result = {
@@ -107,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)
@@ -115,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:
@@ -146,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

View File

@@ -63,6 +63,11 @@ class ProjectMembersEndpoint(BaseAPIView):
def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
if not deploy_board:
return Response(
{"error": "Invalid anchor"},
status=status.HTTP_404_NOT_FOUND,
)
members = ProjectMember.objects.filter(
project=deploy_board.project,
@@ -71,10 +76,7 @@ class ProjectMembersEndpoint(BaseAPIView):
).values(
"id",
"member",
"member__first_name",
"member__last_name",
"member__display_name",
"project",
"workspace",
"member__avatar",
)
return Response(members, status=status.HTTP_200_OK)

View File

@@ -138,7 +138,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)
@@ -155,7 +155,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)
@@ -174,7 +174,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)
@@ -188,7 +188,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)
@@ -203,7 +203,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)
@@ -222,7 +222,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)
@@ -238,7 +238,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)
@@ -254,7 +254,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",
@@ -278,7 +278,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"}
@@ -296,7 +296,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
@@ -316,7 +316,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
@@ -329,6 +329,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):
@@ -337,9 +387,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

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

View File

@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.mjs",

View File

@@ -1,10 +1,6 @@
export type TPublicMember = {
id: string;
member: string;
member__avatar: string;
member__first_name: string;
member__last_name: string;
member__display_name: string;
project: string;
workspace: string;
member__avatar: string;
};

View File

@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"license": "AGPL-3.0",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"license": "AGPL-3.0",
"type": "module",

View File

@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/codemods",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"scripts": {
"test": "vitest run",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"license": "AGPL-3.0",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "1.2.1",
"version": "1.2.2",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "1.2.1",
"version": "1.2.2",
"private": true,
"license": "AGPL-3.0",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"type": "module",

View File

@@ -196,12 +196,8 @@ export type TProfileViews = "assigned" | "created" | "subscribed";
export type TPublicMember = {
id: string;
member: string;
member__avatar: string;
member__first_name: string;
member__last_name: string;
member__display_name: string;
project: string;
workspace: string;
member__avatar: string;
};
// export interface ICurrentUser {

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "1.2.1",
"version": "1.2.2",
"license": "AGPL-3.0",
"private": true,
"files": [

View File

@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "1.2.1",
"version": "1.2.2",
"sideEffects": false,
"license": "AGPL-3.0",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "1.2.1",
"version": "1.2.2",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,