Addresses SSRF risk

This commit is contained in:
mchev
2026-03-21 19:14:51 +01:00
parent d4e19646ee
commit 07757e747e
7 changed files with 124 additions and 4 deletions

View File

@@ -17,3 +17,7 @@ DB_PASSWORD=
SESSION_DOMAIN=null SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAIN= SANCTUM_STATEFUL_DOMAIN=
TRUSTED_PROXIES="*" TRUSTED_PROXIES="*"
# Dompdf: keep false so untrusted HTML in PDF notes cannot trigger outbound requests (SSRF).
# Set true only if you fully trust all PDF HTML and need remote images/CSS.
DOMPDF_ENABLE_REMOTE=false

View File

@@ -8,6 +8,7 @@ use App\Facades\PDF;
use App\Mail\SendEstimateMail; use App\Mail\SendEstimateMail;
use App\Services\SerialNumberFormatter; use App\Services\SerialNumberFormatter;
use App\Space\PdfTemplateUtils; use App\Space\PdfTemplateUtils;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait; use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait; use App\Traits\HasCustomFieldsTrait;
use Carbon\Carbon; use Carbon\Carbon;
@@ -475,7 +476,7 @@ class Estimate extends Model implements HasMedia
public function getNotes() public function getNotes()
{ {
return $this->getFormattedString($this->notes); return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
} }
public function getEmailAttachmentSetting() public function getEmailAttachmentSetting()

View File

@@ -8,6 +8,7 @@ use App\Facades\PDF;
use App\Mail\SendInvoiceMail; use App\Mail\SendInvoiceMail;
use App\Services\SerialNumberFormatter; use App\Services\SerialNumberFormatter;
use App\Space\PdfTemplateUtils; use App\Space\PdfTemplateUtils;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait; use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait; use App\Traits\HasCustomFieldsTrait;
use Carbon\Carbon; use Carbon\Carbon;
@@ -644,7 +645,7 @@ class Invoice extends Model implements HasMedia
public function getNotes() public function getNotes()
{ {
return $this->getFormattedString($this->notes); return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
} }
public function getEmailString($body) public function getEmailString($body)

View File

@@ -6,6 +6,7 @@ use App\Facades\Hashids;
use App\Jobs\GeneratePaymentPdfJob; use App\Jobs\GeneratePaymentPdfJob;
use App\Mail\SendPaymentMail; use App\Mail\SendPaymentMail;
use App\Services\SerialNumberFormatter; use App\Services\SerialNumberFormatter;
use App\Support\PdfHtmlSanitizer;
use App\Traits\GeneratesPdfTrait; use App\Traits\GeneratesPdfTrait;
use App\Traits\HasCustomFieldsTrait; use App\Traits\HasCustomFieldsTrait;
use Barryvdh\DomPDF\Facade\Pdf as PDF; use Barryvdh\DomPDF\Facade\Pdf as PDF;
@@ -433,7 +434,7 @@ class Payment extends Model implements HasMedia
public function getNotes() public function getNotes()
{ {
return $this->getFormattedString($this->notes); return PdfHtmlSanitizer::sanitize($this->getFormattedString($this->notes));
} }
public function getEmailBody($body) public function getEmailBody($body)

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Support;
use DOMDocument;
use DOMElement;
use DOMXPath;
final class PdfHtmlSanitizer
{
/**
* Sanitize HTML that will be rendered inside Dompdf. Removes tags that can trigger
* network requests (SSRF) or carry executable handlers, while keeping common
* text-formatting markup used in invoice/estimate/payment notes.
*/
public static function sanitize(string $html): string
{
if ($html === '') {
return '';
}
$allowedTags = '<br><br/><p><b><strong><i><em><u><ol><ul><li><table><tr><td><th><thead><tbody><tfoot><h1><h2><h3><h4><blockquote>';
$html = strip_tags($html, $allowedTags);
$previous = libxml_use_internal_errors(true);
$doc = new DOMDocument;
$wrapped = '<?xml encoding="UTF-8"?><div id="__pdf_notes">'.$html.'</div>';
$doc->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$xpath = new DOMXPath($doc);
$root = $xpath->query('//*[@id="__pdf_notes"]')->item(0);
if (! $root instanceof DOMElement) {
return $html;
}
foreach ($xpath->query('.//*', $root) as $element) {
if (! $element instanceof DOMElement) {
continue;
}
$toRemove = [];
foreach ($element->attributes as $attr) {
if (self::shouldRemoveAttribute($attr->name)) {
$toRemove[] = $attr->name;
}
}
foreach ($toRemove as $name) {
$element->removeAttribute($name);
}
}
$result = '';
foreach ($root->childNodes as $child) {
$result .= $doc->saveHTML($child);
}
return $result;
}
private static function shouldRemoveAttribute(string $name): bool
{
$lower = strtolower($name);
if (str_starts_with($lower, 'on')) {
return true;
}
return in_array($lower, [
'style',
'src',
'href',
'srcset',
'srcdoc',
'poster',
'formaction',
'xlink:href',
], true);
}
}

View File

@@ -227,7 +227,7 @@ return [
* *
* @var bool * @var bool
*/ */
'enable_remote' => true, 'enable_remote' => env('DOMPDF_ENABLE_REMOTE', false),
/** /**
* A ratio applied to the fonts height to be more like browsers' line height * A ratio applied to the fonts height to be more like browsers' line height

View File

@@ -0,0 +1,27 @@
<?php
use App\Support\PdfHtmlSanitizer;
it('removes img tags that could trigger SSRF during PDF rendering', function () {
$html = "<p>Hi</p><img src='http://example.com/x'>";
expect(PdfHtmlSanitizer::sanitize($html))->not->toContain('<img');
});
it('preserves basic formatting tags', function () {
$html = '<p><b>Bold</b> and <i>italic</i></p>';
expect(PdfHtmlSanitizer::sanitize($html))->toContain('<b>')->toContain('<i>');
});
it('strips style and link attributes that may carry URLs', function () {
$html = '<p style="background:url(http://example.com/)">x</p><a href="http://evil">y</a>';
$out = PdfHtmlSanitizer::sanitize($html);
expect($out)->not->toContain('style=')->not->toContain('href=')->not->toContain('example.com');
});
it('returns empty string for empty input', function () {
expect(PdfHtmlSanitizer::sanitize(''))->toBe('');
});