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'); +});