diff --git a/.env.example b/.env.example index 0774d263..8d70d2d1 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,17 @@ TRUSTED_PROXIES="*" # 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 + # InvoiceShelf marketplace and updater base URL. # Defaults to https://invoiceshelf.com. Override to point at a local website # checkout for development: diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php index af01ffb1..f6718b28 100644 --- a/app/Http/Requests/PDFConfigurationRequest.php +++ b/app/Http/Requests/PDFConfigurationRequest.php @@ -3,7 +3,9 @@ namespace App\Http\Requests; use App\Rules\PublicHttpUrl; +use App\Support\Pdf\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 private-network + // check; anything else is still held to it. See GotenbergHostPolicy. + $isDeclaredHost = GotenbergHostPolicy::isExemptFromPrivateNetworkGuard( + $this->input('gotenberg_host') + ); + return [ 'pdf_driver' => [ 'required', @@ -38,7 +46,7 @@ class PDFConfigurationRequest extends FormRequest 'gotenberg_host' => [ 'required', 'url', - new PublicHttpUrl, + Rule::when(! $isDeclaredHost, [new PublicHttpUrl]), ], 'gotenberg_papersize' => [ 'required', diff --git a/app/Support/Pdf/GotenbergHostPolicy.php b/app/Support/Pdf/GotenbergHostPolicy.php new file mode 100644 index 00000000..aa30d281 --- /dev/null +++ b/app/Support/Pdf/GotenbergHostPolicy.php @@ -0,0 +1,83 @@ +getMessage()); + // the rendered HTML to, and whose response is streamed back as the PDF. + // Block private/reserved/link-local targets even if set via env/seed/stale + // config or reachable through DNS rebinding. 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::isExemptFromPrivateNetworkGuard((string) $host)) { + try { + PrivateNetworkGuard::assertAllowed((string) $host); + } catch (BlockedUrlException $e) { + throw new \InvalidArgumentException('Invalid Gotenberg host: '.$e->getMessage()); + } } $request = Gotenberg::chromium($host) diff --git a/config/pdf.php b/config/pdf.php index a5b26cb7..cbf65a7a 100644 --- a/config/pdf.php +++ b/config/pdf.php @@ -30,6 +30,16 @@ return [ 'gotenberg' => [ '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/GotenbergHostValidationTest.php b/tests/Unit/GotenbergHostValidationTest.php index d517106e..3752dc71 100644 --- a/tests/Unit/GotenbergHostValidationTest.php +++ b/tests/Unit/GotenbergHostValidationTest.php @@ -1,14 +1,26 @@ 'gotenberg', + 'gotenberg_host' => $url, + ])->rules(); + + return Validator::make(['gotenberg_host' => $url], ['gotenberg_host' => $rules['gotenberg_host']]); +} + test('gotenberg host rejects private, loopback and link-local addresses', function (string $url) { - $rules = PDFConfigurationRequest::create('/', 'POST', ['pdf_driver' => 'gotenberg'])->rules(); - - $validator = Validator::make(['gotenberg_host' => $url], ['gotenberg_host' => $rules['gotenberg_host']]); - - expect($validator->fails())->toBeTrue(); + expect(validateGotenbergHost($url)->fails())->toBeTrue(); })->with([ 'http://127.0.0.1', 'http://169.254.169.254', @@ -17,9 +29,75 @@ test('gotenberg host rejects private, loopback and link-local addresses', functi ]); test('gotenberg host allows a public address', function () { - $rules = PDFConfigurationRequest::create('/', 'POST', ['pdf_driver' => 'gotenberg'])->rules(); - - $validator = Validator::make(['gotenberg_host' => 'http://8.8.8.8'], ['gotenberg_host' => $rules['gotenberg_host']]); - - expect($validator->errors()->has('gotenberg_host'))->toBeFalse(); + expect(validateGotenbergHost('http://8.8.8.8')->errors()->has('gotenberg_host'))->toBeFalse(); +}); + +test('gotenberg host accepts the private host declared in the environment', 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 a super 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('an unset allowlist exempts nothing', function () { + config(['pdf.connections.gotenberg.allowed_private_host' => null]); + + expect(validateGotenbergHost('http://10.0.0.5:3000')->fails())->toBeTrue(); +}); + +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::isExemptFromPrivateNetworkGuard($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::isExemptFromPrivateNetworkGuard($submitted))->toBeFalse(); +})->with([ + 'http://pdf:3001', + 'https://pdf:3000', + 'http://pdf', + 'http://other:3000', + 'http://pdf:3000/render', + 'not a url', + '', +]); + +/** + * The driver guard is the authoritative layer — it re-checks at request 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('gotenberg 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(InvalidArgumentException::class, 'Invalid Gotenberg host'); });