Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 47 additions & 47 deletions apps/api/plane/app/serializers/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,53 @@ class PageBinaryUpdateSerializer(serializers.Serializer):
description_html = serializers.CharField(required=False, allow_blank=True)
description_json = serializers.JSONField(required=False, allow_null=True)

def validate_description_binary(self, value):
"""Validate the base64-encoded binary data"""
if not value:
return value

try:
# Decode the base64 data
binary_data = base64.b64decode(value)

# Validate the binary data
is_valid, error_message = validate_binary_data(binary_data)
if not is_valid:
raise serializers.ValidationError(f"Invalid binary data: {error_message}")

return binary_data
except Exception as e:
if isinstance(e, serializers.ValidationError):
raise
raise serializers.ValidationError("Failed to decode base64 data")

def validate_description_html(self, value):
"""Validate the HTML content"""
if not value:
return value

# Use the validation function from utils
is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)

# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value

def update(self, instance, validated_data):
"""Update the page instance with validated data"""
if "description_binary" in validated_data:
instance.description_binary = validated_data.get("description_binary")

if "description_html" in validated_data:
instance.description_html = validated_data.get("description_html")

if "description_json" in validated_data:
instance.description_json = validated_data.get("description_json")

instance.save()
return instance


class PageCollectionSerializer(BaseSerializer):
page_ids = serializers.SerializerMethodField()
Expand Down Expand Up @@ -293,50 +340,3 @@ class Meta:
"created_at",
"updated_at",
]

def validate_description_binary(self, value):
"""Validate the base64-encoded binary data"""
if not value:
return value

try:
# Decode the base64 data
binary_data = base64.b64decode(value)

# Validate the binary data
is_valid, error_message = validate_binary_data(binary_data)
if not is_valid:
raise serializers.ValidationError(f"Invalid binary data: {error_message}")

return binary_data
except Exception as e:
if isinstance(e, serializers.ValidationError):
raise
raise serializers.ValidationError("Failed to decode base64 data")

def validate_description_html(self, value):
"""Validate the HTML content"""
if not value:
return value

# Use the validation function from utils
is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)

# Return sanitized HTML if available, otherwise return original
return sanitized_html if sanitized_html is not None else value

def update(self, instance, validated_data):
"""Update the page instance with validated data"""
if "description_binary" in validated_data:
instance.description_binary = validated_data.get("description_binary")

if "description_html" in validated_data:
instance.description_html = validated_data.get("description_html")

if "description_json" in validated_data:
instance.description_json = validated_data.get("description_json")

instance.save()
return instance
62 changes: 56 additions & 6 deletions apps/api/plane/middleware/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@

api_logger = logging.getLogger("plane.api.request")

# Upper bound on how much of a request/response body is copied into the audit
# log. Each celery message carries the body twice (log_data + mongo_log), so an
# uncapped body is published to RabbitMQ at ~2x its size. A single oversized
# publish is rejected with `PRECONDITION_FAILED (406)` and takes the whole AMQP
# channel down with it, which then surfaces as a 500 on the *next* unrelated
# request that shares the connection (e.g. `recent_visited_task.delay()` while
# opening a project or page). Capping here keeps one big API payload from
# breaking web navigation for everyone.
MAX_LOGGED_BODY_BYTES = 64 * 1024

# Header whose value is the raw API token; never copy it into the audit log.
API_KEY_HEADER = "X-Api-Key"


class RequestLoggerMiddleware:
def __init__(self, get_response):
Expand Down Expand Up @@ -106,14 +119,51 @@ def _safe_decode_body(self, content):
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"

original_size = len(content)
truncated = original_size > MAX_LOGGED_BODY_BYTES
if truncated:
content = content[:MAX_LOGGED_BODY_BYTES]

try:
return content.decode("utf-8")
decoded = content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"
if not truncated:
return "[Could not decode content]"
# The cut may have landed mid-character; that alone shouldn't turn a
# useful truncated body into "[Could not decode content]".
decoded = content.decode("utf-8", errors="replace")

if truncated:
decoded += f"... [truncated: {original_size} bytes total, logged first {MAX_LOGGED_BODY_BYTES}]"
return decoded

def _response_body(self, response):
"""
Returns the response body for logging, or a marker for responses whose
body cannot be read without consuming it.
"""
# StreamingHttpResponse (e.g. page description binary downloads) has no
# `.content`; touching it raises AttributeError.
if getattr(response, "streaming", False):
return "[Streaming Content]"

content = getattr(response, "content", None)
return self._safe_decode_body(content) if content else None

def _safe_headers(self, request):
"""
Stringified request headers with the API token redacted.
"""
headers = {}
for key, value in request.headers.items():
if key.lower() == API_KEY_HEADER.lower():
headers[key] = "[REDACTED]"
else:
headers[key] = value
return str(headers)

def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
api_key = request.headers.get(API_KEY_HEADER)

# If the API key is not present, return
if not api_key:
Expand All @@ -125,9 +175,9 @@ def process_request(self, request, response, request_body):
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"headers": self._safe_headers(request),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_body": self._response_body(response),
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
Expand Down
102 changes: 102 additions & 0 deletions apps/api/plane/tests/unit/middleware/test_api_token_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest.mock import Mock

import pytest

from plane.middleware.logger import (
API_KEY_HEADER,
MAX_LOGGED_BODY_BYTES,
APITokenLogMiddleware,
)


@pytest.fixture
def middleware():
return APITokenLogMiddleware(get_response=Mock())


@pytest.mark.unit
class TestAuditLogBodyCap:
"""
An uncapped body is copied into the celery message twice (log_data +
mongo_log), so one large API payload could exceed RabbitMQ's max message
size. That publish fails with PRECONDITION_FAILED (406) and kills the AMQP
channel, which then surfaces as a 500 on the next unrelated request sharing
the connection. The cap is what keeps that from happening.
"""

def test_small_body_is_logged_verbatim(self, middleware):
assert middleware._safe_decode_body(b"hello") == "hello"

def test_oversized_body_is_truncated(self, middleware):
original_size = MAX_LOGGED_BODY_BYTES * 3
decoded = middleware._safe_decode_body(b"a" * original_size)

assert len(decoded) < original_size
assert decoded.startswith("a" * 100)
assert f"truncated: {original_size} bytes total" in decoded

def test_truncation_survives_a_cut_through_a_multibyte_character(self, middleware):
# "가" is 3 bytes in UTF-8, so the cut lands mid-character.
body = "가".encode("utf-8") * MAX_LOGGED_BODY_BYTES

decoded = middleware._safe_decode_body(body)

assert "truncated" in decoded
assert decoded != "[Could not decode content]"

def test_undecodable_short_body_still_reports_plainly(self, middleware):
assert middleware._safe_decode_body(b"\xff\xfe\x00") == "[Could not decode content]"

def test_binary_signatures_short_circuit(self, middleware):
assert middleware._safe_decode_body(b"%PDF-1.7 ...") == "[Binary Content]"

def test_empty_body_is_none(self, middleware):
assert middleware._safe_decode_body(b"") is None
assert middleware._safe_decode_body(None) is None


@pytest.mark.unit
class TestAuditLogResponseBody:
def test_streaming_response_is_not_consumed(self, middleware):
# StreamingHttpResponse has no `.content`; reading it raises.
response = Mock(spec=["streaming"])
response.streaming = True

assert middleware._response_body(response) == "[Streaming Content]"

def test_regular_response_body_is_read(self, middleware):
response = Mock()
response.streaming = False
response.content = b"ok"

assert middleware._response_body(response) == "ok"

def test_empty_response_body_is_none(self, middleware):
response = Mock()
response.streaming = False
response.content = b""

assert middleware._response_body(response) is None


@pytest.mark.unit
class TestAuditLogHeaderRedaction:
def test_api_key_is_redacted(self, middleware):
request = Mock()
request.headers = {API_KEY_HEADER: "plane_api_secret", "Host": "plane.example.com"}

headers = middleware._safe_headers(request)

assert "plane_api_secret" not in headers
assert "[REDACTED]" in headers
assert "plane.example.com" in headers

def test_api_key_is_redacted_case_insensitively(self, middleware):
request = Mock()
request.headers = {"x-api-key": "plane_api_secret"}

assert "plane_api_secret" not in middleware._safe_headers(request)
Loading