Added basic functionality for an HTML sandbox for relative safely rendering HTML attachments

Fixed #1150
This commit is contained in:
Jan Böhmer 2026-02-24 22:27:33 +01:00
parent a7a1026f9b
commit 63dd344c02
4 changed files with 161 additions and 26 deletions

View file

@ -30,6 +30,7 @@ use App\Form\Filters\AttachmentFilterType;
use App\Services\Attachments\AttachmentManager;
use App\Services\Trees\NodesListBuilder;
use App\Settings\BehaviorSettings\TableSettings;
use App\Settings\SystemSettings\AttachmentsSettings;
use Omines\DataTablesBundle\DataTableFactory;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@ -41,27 +42,50 @@ use Symfony\Component\Routing\Attribute\Route;
class AttachmentFileController extends AbstractController
{
public function __construct(private readonly AttachmentManager $helper)
{
}
#[Route(path: '/attachment/{id}/sandbox', name: 'attachment_html_sandbox')]
public function htmlSandbox(Attachment $attachment, AttachmentsSettings $attachmentsSettings): Response
{
//Check if the sandbox is enabled in the settings, as it can be a security risk if used without proper precautions, so it should be opt-in
if (!$attachmentsSettings->showHTMLAttachments) {
throw $this->createAccessDeniedException('The HTML sandbox for attachments is disabled in the settings, as it can be a security risk if used without proper precautions. Please enable it in the settings if you want to use it.');
}
$this->checkPermissions($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$attachmentContent = file_get_contents($file_path);
$response = $this->render('attachments/html_sandbox.html.twig', [
'attachment' => $attachment,
'content' => $attachmentContent,
]);
//Set an CSP that allows to run inline scripts, styles and images from external ressources, but does not allow any connections or others.
//Also set the sandbox CSP directive with only "allow-script" to run basic scripts
$response->headers->set('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; sandbox allow-scripts;");
//Forbid to embed the attachment render page in an iframe to prevent clickjacking, as it is not used anywhere else for now
$response->headers->set('X-Frame-Options', 'DENY');
return $response;
}
/**
* Download the selected attachment.
*/
#[Route(path: '/attachment/{id}/download', name: 'attachment_download')]
public function download(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
public function download(Attachment $attachment): BinaryFileResponse
{
$this->denyAccessUnlessGranted('read', $attachment);
$this->checkPermissions($attachment);
if ($attachment->isSecure()) {
$this->denyAccessUnlessGranted('show_private', $attachment);
}
if (!$attachment->hasInternal()) {
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded
@ -74,7 +98,20 @@ class AttachmentFileController extends AbstractController
* View the attachment.
*/
#[Route(path: '/attachment/{id}/view', name: 'attachment_view')]
public function view(Attachment $attachment, AttachmentManager $helper): BinaryFileResponse
public function view(Attachment $attachment): BinaryFileResponse
{
$this->checkPermissions($attachment);
$file_path = $this->helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
return $response;
}
private function checkPermissions(Attachment $attachment): void
{
$this->denyAccessUnlessGranted('read', $attachment);
@ -86,17 +123,9 @@ class AttachmentFileController extends AbstractController
throw $this->createNotFoundException('The file for this attachment is external and not stored locally!');
}
if (!$helper->isInternalFileExisting($attachment)) {
if (!$this->helper->isInternalFileExisting($attachment)) {
throw $this->createNotFoundException('The file associated with the attachment is not existing!');
}
$file_path = $helper->toAbsoluteInternalFilePath($attachment);
$response = new BinaryFileResponse($file_path);
//Set header content disposition, so that the file will be downloaded
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE);
return $response;
}
#[Route(path: '/attachment/list', name: 'attachment_list')]

View file

@ -58,4 +58,11 @@ class AttachmentsSettings
envVar: "bool:ATTACHMENT_DOWNLOAD_BY_DEFAULT", envVarMode: EnvVarMode::OVERWRITE
)]
public bool $downloadByDefault = false;
#[SettingsParameter(
label: new TM("settings.system.attachments.showHTMLAttachments"),
description: new TM("settings.system.attachments.showHTMLAttachments.help"),
envVar: "bool:ATTACHMENT_SHOW_HTML", envVarMode: EnvVarMode::OVERWRITE
)]
public bool $showHTMLAttachments = false;
}

View file

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale | replace({"_": "-"}) }}"
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{# The content block is already escaped. so we must not escape it again. #}
<title></title>
<style>
/* Reset margins and stop the page from scrolling */
body, html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: sans-serif;
}
/* The Flex Container */
.wrapper {
display: flex;
flex-direction: column;
height: 100vh;
}
/* The Warning Header */
.warning-bar {
background-color: #ff4d4d;
color: white;
padding: 10px 20px;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
z-index: 10; /* Keep it above the iframe */
}
/* The Iframe: The 'flex: 1' makes it fill all remaining space */
.content-frame {
flex: 1;
border: none;
width: 100%;
}
</style>
</head>
<body>
{% block body %}
{# We have a fullscreen iframe, with an warning on top #}
<div class="wrapper">
<header>
<header class="warning-bar">
<b>⚠️ {% trans%}attachment.sandbox.warning{% endtrans %}</b>
<br>
<small>
{% trans%}[Attachment]{% endtrans%}: {{ attachment.name }} / {{ attachment.filename ?? "" }} ({% trans%}id.label{% endtrans %}: {{ attachment.id }})
<a href="{{ path("homepage") }}" style="color: white; margin-left: 15px;">{% trans%}attachment.sandbox.back_to_partdb{% endtrans %}</a>
</small>
</header>
</header>
<iframe referrerpolicy="no-referrer" class="content-frame"
{# When changing this sandbox, also change the sandbox CSP in the controller #}
sandbox="allow-scripts"
srcdoc="{{ content|e('html_attr') }}"
></iframe>
</div>
{% endblock %}
</body>
</html>

View file

@ -12593,5 +12593,29 @@ Buerklin-API Authentication server:
<target>When selected, more details will be fetched from canopy when creating a part. This causes an additional API request, but gives product bullet points and category info.</target>
</segment>
</unit>
<unit id="D055xh8" name="attachment.sandbox.warning">
<segment>
<source>attachment.sandbox.warning</source>
<target>WARNING: You are viewing an user uploaded attachment. This is untrusted content. Proceed with care.</target>
</segment>
</unit>
<unit id="bRcdnJK" name="attachment.sandbox.back_to_partdb">
<segment>
<source>attachment.sandbox.back_to_partdb</source>
<target>Back to Part-DB</target>
</segment>
</unit>
<unit id="MzyA7N8" name="settings.system.attachments.showHTMLAttachments">
<segment>
<source>settings.system.attachments.showHTMLAttachments</source>
<target>Show uploaded HTML file attachments (sandboxed)</target>
</segment>
</unit>
<unit id="V_LJkRy" name="settings.system.attachments.showHTMLAttachments.help">
<segment>
<source>settings.system.attachments.showHTMLAttachments.help</source>
<target>⚠️ When enabled, user uploaded HTML attachments can be viewed directly in the browser. Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled, if you trust the users who can upload files.</target>
</segment>
</unit>
</file>
</xliff>