From 4d6ece6230d1234b6a91fa95ead658d189c64d85 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:46:53 +0200 Subject: [PATCH] fix(gotenberg): allow a declared private host past the SSRF guard (#700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of InvoiceShelf/InvoiceShelf#691 to 2.x, for the reporter of #688 who is on 2.4.1. The guard added in the 2.4.0 security round rejects private addresses, which includes the shipped default host `http://pdf:3000` and every Docker Compose sidecar deployment. So the guard rejects its own default and Gotenberg cannot be configured at all on the standard setup — a usability regression introduced by a security patch, which is why this lands during the 2.x feature freeze. GOTENBERG_ALLOWED_PRIVATE_HOST names the single host that may skip the check. It is deliberately not a boolean and not settable from the admin UI: the driver streams the upstream response body back as the PDF, so a blanket "allow private" switch would let gotenberg_host be repointed at a link-local metadata endpoint and the response read back. Naming one host keeps the sidecar working while every other private target stays blocked. GotenbergHostPolicy owns the comparison so the save-time rule and the runtime driver guard cannot drift, normalising case, trailing slash and surrounding whitespace on both sides. Note this differs from 3.x in one respect: SafeRemoteUrl rejects hosts that do not resolve, where 3.x's PrivateNetworkGuard lets them through. That behaviour is unchanged here — a typo'd host is still refused at save time on 2.x, which is the friendlier outcome. --- .env.example | 11 ++ app/Http/Requests/PDFConfigurationRequest.php | 10 +- .../PDFDrivers/GotenbergPDFDriver.php | 9 +- app/Support/GotenbergHostPolicy.php | 83 ++++++++++++++ config/pdf.php | 10 ++ tests/Unit/Rules/GotenbergHostPolicyTest.php | 101 ++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 app/Support/GotenbergHostPolicy.php create mode 100644 tests/Unit/Rules/GotenbergHostPolicyTest.php diff --git a/.env.example b/.env.example index 89e85750..5a317c1d 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,14 @@ 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 + +# Gotenberg (optional alternative PDF driver; the default is dompdf). +# PDF_DRIVER=gotenberg +# GOTENBERG_HOST=http://pdf:3000 +# GOTENBERG_PAPERSIZE="210mm 297mm" +# +# Gotenberg is normally a sidecar on a private network, which the SSRF guard +# rejects. Name that one host to exempt it — and only it. Every other private +# target stays blocked, so the host cannot later be repointed at an internal +# service. Leave unset unless you actually run Gotenberg privately. +# GOTENBERG_ALLOWED_PRIVATE_HOST=http://pdf:3000 diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php index 4cfaf646..ddc1dbff 100644 --- a/app/Http/Requests/PDFConfigurationRequest.php +++ b/app/Http/Requests/PDFConfigurationRequest.php @@ -3,7 +3,9 @@ namespace App\Http\Requests; use App\Rules\SafeRemoteUrl; +use App\Support\GotenbergHostPolicy; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class PDFConfigurationRequest extends FormRequest { @@ -30,6 +32,12 @@ class PDFConfigurationRequest extends FormRequest ]; case 'gotenberg': + // The operator-declared Gotenberg host skips the public-address + // check; anything else is still held to it. See GotenbergHostPolicy. + $isDeclaredHost = GotenbergHostPolicy::isExemptFromSafeRemoteUrl( + $this->input('gotenberg_host') + ); + return [ 'pdf_driver' => [ 'required', @@ -38,7 +46,7 @@ class PDFConfigurationRequest extends FormRequest 'gotenberg_host' => [ 'required', 'url', - new SafeRemoteUrl, + Rule::when(! $isDeclaredHost, [new SafeRemoteUrl]), ], 'gotenberg_papersize' => [ 'required', diff --git a/app/Services/PDFDrivers/GotenbergPDFDriver.php b/app/Services/PDFDrivers/GotenbergPDFDriver.php index 12b187c8..ef7c0af3 100644 --- a/app/Services/PDFDrivers/GotenbergPDFDriver.php +++ b/app/Services/PDFDrivers/GotenbergPDFDriver.php @@ -3,6 +3,7 @@ namespace App\Services\PDFDrivers; use App\Rules\SafeRemoteUrl; +use App\Support\GotenbergHostPolicy; use Gotenberg\Gotenberg; use Gotenberg\Stream; use Illuminate\Http\Response; @@ -47,8 +48,12 @@ class GotenbergPDFDriver // Defense in depth against SSRF: a host that bypassed request-time // validation (env/seed/stale config, or DNS rebinding) must still not - // be able to target internal/private addresses. - if (! SafeRemoteUrl::isSafe((string) $host)) { + // be able to target internal/private addresses. The single exception is + // the host the operator declared in GOTENBERG_ALLOWED_PRIVATE_HOST, + // which is how a sidecar deployment is supported — see + // GotenbergHostPolicy. + if (! GotenbergHostPolicy::isExemptFromSafeRemoteUrl((string) $host) + && ! SafeRemoteUrl::isSafe((string) $host)) { throw new \RuntimeException('Refusing to render PDF: unsafe Gotenberg host.'); } diff --git a/app/Support/GotenbergHostPolicy.php b/app/Support/GotenbergHostPolicy.php new file mode 100644 index 00000000..ea8f97a1 --- /dev/null +++ b/app/Support/GotenbergHostPolicy.php @@ -0,0 +1,83 @@ + [ 'host' => env('GOTENBERG_HOST', 'http://pdf:3000'), 'papersize' => env('GOTENBERG_PAPERSIZE', '210mm 297mm'), + + /* + * Gotenberg usually runs as a sidecar on a private network, which the + * SSRF guard rejects. Name that one host here to exempt it — e.g. + * GOTENBERG_ALLOWED_PRIVATE_HOST=http://pdf:3000. Only this exact value + * is exempt; the guard still blocks every other private target, so the + * host setting cannot be repointed at an internal service. No default: + * the `host` fallback above must never be trusted implicitly. + */ + 'allowed_private_host' => env('GOTENBERG_ALLOWED_PRIVATE_HOST'), ], ], diff --git a/tests/Unit/Rules/GotenbergHostPolicyTest.php b/tests/Unit/Rules/GotenbergHostPolicyTest.php new file mode 100644 index 00000000..9474dcae --- /dev/null +++ b/tests/Unit/Rules/GotenbergHostPolicyTest.php @@ -0,0 +1,101 @@ + 'gotenberg', + 'gotenberg_host' => $url, + ])->rules(); + + return Validator::make(['gotenberg_host' => $url], ['gotenberg_host' => $rules['gotenberg_host']]); +} + +test('a private gotenberg host is rejected when nothing is declared', function (string $url) { + config(['pdf.connections.gotenberg.allowed_private_host' => null]); + + expect(validateGotenbergHost($url)->fails())->toBeTrue(); +})->with([ + 'http://127.0.0.1:3000', + 'http://169.254.169.254', + 'http://10.0.0.5:3000', + 'http://192.168.1.1:3000', +]); + +test('the private host declared in the environment is accepted', function () { + config(['pdf.connections.gotenberg.allowed_private_host' => 'http://10.0.0.5:3000']); + + expect(validateGotenbergHost('http://10.0.0.5:3000')->errors()->has('gotenberg_host'))->toBeFalse(); +}); + +/** + * The point of naming the host rather than flipping a boolean: declaring one + * private host must not open the guard for any other. Without this, an operator + * who enables the sidecar also hands an admin the ability to repoint the setting + * at a cloud metadata endpoint and read the response back as a "PDF". + */ +test('declaring one private host does not exempt any other', function (string $url) { + config(['pdf.connections.gotenberg.allowed_private_host' => 'http://pdf:3000']); + + expect(validateGotenbergHost($url)->fails())->toBeTrue(); +})->with([ + 'http://169.254.169.254', + 'http://127.0.0.1:3000', + 'http://10.0.0.5:3000', +]); + +test('the declared host is matched ignoring case and trailing slash', function (string $configured, string $submitted) { + config(['pdf.connections.gotenberg.allowed_private_host' => $configured]); + + expect(GotenbergHostPolicy::isExemptFromSafeRemoteUrl($submitted))->toBeTrue(); +})->with([ + ['http://pdf:3000', 'http://pdf:3000/'], + ['http://pdf:3000/', 'http://pdf:3000'], + ['HTTP://PDF:3000', 'http://pdf:3000'], + [' http://pdf:3000 ', 'http://pdf:3000'], +]); + +test('the policy rejects hosts that differ in any meaningful part', function (string $submitted) { + config(['pdf.connections.gotenberg.allowed_private_host' => 'http://pdf:3000']); + + expect(GotenbergHostPolicy::isExemptFromSafeRemoteUrl($submitted))->toBeFalse(); +})->with([ + 'http://pdf:3001', + 'https://pdf:3000', + 'http://pdf', + 'http://other:3000', + 'http://pdf:3000/render', + 'not a url', + '', +]); + +test('an unset allowlist exempts nothing', function () { + config(['pdf.connections.gotenberg.allowed_private_host' => null]); + + expect(GotenbergHostPolicy::isExemptFromSafeRemoteUrl('http://pdf:3000'))->toBeFalse(); +}); + +/** + * The driver guard is the authoritative layer — it re-checks at render time, so + * it has to agree with the validation rule. Asserted on the blocking path only: + * it throws before any HTTP call is attempted, so the test never touches the + * network. + */ +test('the driver still blocks a private host that was not declared', function () { + config([ + 'pdf.connections.gotenberg.host' => 'http://169.254.169.254', + 'pdf.connections.gotenberg.papersize' => '210mm 297mm', + 'pdf.connections.gotenberg.allowed_private_host' => 'http://pdf:3000', + ]); + + expect(fn () => (new GotenbergPDFDriver)->loadView('app.pdf.invoice.invoice1')) + ->toThrow(RuntimeException::class, 'unsafe Gotenberg host'); +});