Skip to content
Open
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
24 changes: 23 additions & 1 deletion apps/api/plane/bgtasks/copy_s3_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
from celery import shared_task
from plane.utils.url import normalize_url_path

# (connect, read) timeout for the Live service call. `requests` has no default
# timeout, so without one a Live service that stalls after accepting the connection
# pins a Celery worker forever. The read budget is wide: conversion is genuinely slow.
LIVE_REQUEST_TIMEOUT = (5, 30)


def get_entity_id_field(entity_type, entity_id):
entity_mapping = {
Expand Down Expand Up @@ -77,7 +82,24 @@ def sync_with_external_service(entity_name, description_html):

url = normalize_url_path(f"{live_url}/convert-document/")

response = requests.post(url, json=data, headers=None)
# The Live service authenticates this endpoint with a shared secret.
# Without the header the request is rejected as 401.
secret_key = settings.LIVE_SERVER_SECRET_KEY
if not secret_key:
log_exception(
Exception(
"LIVE_SERVER_SECRET_KEY is not configured; skipping document conversion "
"for duplication. Set it to the same value as the Live service."
)
)
return {}

response = requests.post(
url,
json=data,
headers={"live-server-secret-key": secret_key},
timeout=LIVE_REQUEST_TIMEOUT,
)
if response.status_code == 200:
return response.json()
except requests.RequestException as e:
Expand Down
4 changes: 4 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,10 @@

LIVE_URL = urljoin(LIVE_BASE_URL, LIVE_BASE_PATH) if LIVE_BASE_URL else None

# Shared secret for server-to-server calls into the Live service. Must match the
# Live container's LIVE_SERVER_SECRET_KEY.
LIVE_SERVER_SECRET_KEY = os.environ.get("LIVE_SERVER_SECRET_KEY")

# WEB URL
WEB_URL = os.environ.get("WEB_URL")

Expand Down
133 changes: 133 additions & 0 deletions apps/api/plane/tests/unit/bg_tasks/test_copy_s3_object_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""
Authentication of the API -> Live `/convert-document/` call.

Live now gates the endpoint on the `live-server-secret-key` header, and this task is
its only caller: the header must actually be sent, and a missing key must fail loudly
rather than firing a request that can only 401. Pure unit tests — no DB, no network.
"""

from unittest.mock import MagicMock, patch

import requests
from django.test import override_settings

from plane.bgtasks.copy_s3_object import LIVE_REQUEST_TIMEOUT, sync_with_external_service

LIVE_URL = "http://live:3000/live/"
SECRET = "unit-test-live-secret"


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET)
def test_sends_secret_key_header():
"""The shared secret must travel on the request, or Live returns 401."""
response = MagicMock(status_code=200)
response.json.return_value = {"description_json": {}, "description_binary": "AA=="}

with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post:
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {"description_json": {}, "description_binary": "AA=="}
mock_post.assert_called_once()

headers = mock_post.call_args.kwargs["headers"]
assert headers == {"live-server-secret-key": SECRET}


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET)
def test_sends_bounded_timeout():
"""
`requests` has no default timeout. Without one, a Live service that accepts the
connection and then stalls would pin a Celery worker indefinitely.
"""
response = MagicMock(status_code=200)
response.json.return_value = {}

with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post:
sync_with_external_service("PAGE", "<p>hello</p>")

timeout = mock_post.call_args.kwargs["timeout"]
assert timeout == LIVE_REQUEST_TIMEOUT
connect, read = timeout
assert 0 < connect <= 10
assert 0 < read <= 60


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET)
def test_timeout_is_swallowed_not_raised():
"""A stalled Live service must degrade duplication, not fail the whole task."""
with patch(
"plane.bgtasks.copy_s3_object.requests.post",
side_effect=requests.exceptions.ReadTimeout("timed out"),
):
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {}


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=None)
def test_missing_secret_key_skips_request():
"""
With no key configured the call could only ever 401, so it is not attempted.
Returning {} leaves `description_binary` untouched upstream (the caller guards
on `if external_data:`), which degrades duplication rather than corrupting it.
"""
with (
patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post,
patch("plane.bgtasks.copy_s3_object.log_exception") as mock_log,
):
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {}
mock_post.assert_not_called()
# The misconfiguration must be surfaced, not swallowed silently.
mock_log.assert_called_once()


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY="")
def test_empty_secret_key_treated_as_missing():
"""An empty string is a misconfiguration, not a valid credential."""
with (
patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post,
patch("plane.bgtasks.copy_s3_object.log_exception"),
):
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {}
mock_post.assert_not_called()


@override_settings(LIVE_URL=None, LIVE_SERVER_SECRET_KEY=SECRET)
def test_no_live_url_short_circuits():
"""Deployments without a Live service must not attempt the call at all."""
with patch("plane.bgtasks.copy_s3_object.requests.post") as mock_post:
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {}
mock_post.assert_not_called()


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET)
def test_non_200_returns_empty_dict():
"""A rejected call (e.g. a stale key on one side) must not raise."""
with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=MagicMock(status_code=401)):
result = sync_with_external_service("PAGE", "<p>hello</p>")

assert result == {}


@override_settings(LIVE_URL=LIVE_URL, LIVE_SERVER_SECRET_KEY=SECRET)
def test_variant_depends_on_entity_name():
"""Guard the existing contract while changing the auth around it."""
response = MagicMock(status_code=200)
response.json.return_value = {}

with patch("plane.bgtasks.copy_s3_object.requests.post", return_value=response) as mock_post:
sync_with_external_service("PAGE", "<p>x</p>")
assert mock_post.call_args.kwargs["json"]["variant"] == "rich"

sync_with_external_service("ISSUE", "<p>x</p>")
assert mock_post.call_args.kwargs["json"]["variant"] == "document"
10 changes: 9 additions & 1 deletion apps/live/src/controllers/document.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import type { Request, Response } from "express";
import { z } from "zod";
// helpers
import { Controller, Post } from "@plane/decorators";
import { Controller, Middleware, Post } from "@plane/decorators";
import { convertHTMLDocumentToAllFormats } from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { requireSecretKey } from "@/lib/auth-middleware";
import type { TConvertDocumentRequestBody } from "@/types";

// Define the schema with more robust validation
Expand All @@ -25,7 +26,14 @@ const convertDocumentSchema = z.object({

@Controller("/convert-document")
export class DocumentController {
/**
* Server-to-server only: the sole caller is the API's `copy_s3_object` background
* task. It was previously reachable unauthenticated by anyone who could hit the
* Live service, making an expensive HTML -> Y.js conversion free compute for the
* internet. Callers must now present `live-server-secret-key`.
*/
@Post("/")
@Middleware(requireSecretKey)
async convertDocument(req: Request, res: Response) {
try {
// Validate request body
Expand Down
17 changes: 16 additions & 1 deletion apps/live/src/lib/pdf/node-renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Image, Link, Text, View } from "@react-pdf/renderer";
import type { Style } from "@react-pdf/types";
import type { ReactElement } from "react";
import { CORE_EXTENSIONS } from "@plane/editor";
import { isSafeImageSrc } from "@/lib/url-security";
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
import { applyMarks } from "./mark-renderers";
Expand Down Expand Up @@ -272,6 +273,17 @@ export const nodeRenderers: NodeRendererRegistry = {
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };

// SSRF guard: `src` comes from page content, and @react-pdf/image will fetch()
// any URL with a host — including internal Docker service names — or
// fs.readFile() a bare path. Anything we won't fetch renders as a placeholder.
if (!isSafeImageSrc(src)) {
return (
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
<Text style={pdfStyles.imagePlaceholderText}>[Image unavailable]</Text>
</View>
);
}

return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image
Expand Down Expand Up @@ -308,7 +320,10 @@ export const nodeRenderers: NodeRendererRegistry = {
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };

if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
// `resolvedSrc` is normally the pre-fetched `data:image/…` URI, or the raw asset
// id if resolution failed. Same guard as the `image` renderer: a
// startsWith("http") check would happily pass http://api:8000/.
if (!isSafeImageSrc(resolvedSrc)) {
return (
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
Expand Down
Loading
Loading