mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-08 16:14:13 +00:00
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.
75 lines
2.3 KiB
PHP
75 lines
2.3 KiB
PHP
<?php
|
|
|
|
namespace App\Services\PDFDrivers;
|
|
|
|
use App\Rules\SafeRemoteUrl;
|
|
use App\Support\GotenbergHostPolicy;
|
|
use Gotenberg\Gotenberg;
|
|
use Gotenberg\Stream;
|
|
use Illuminate\Http\Response;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
|
|
class GotenbergPDFResponse
|
|
{
|
|
/** @var ResponseInterface */
|
|
protected $response;
|
|
|
|
public function __construct($stream)
|
|
{
|
|
$this->response = $stream;
|
|
}
|
|
|
|
public function stream(string $filename = 'document.pdf'): Response
|
|
{
|
|
$output = $this->response->getBody();
|
|
|
|
return new Response($output, 200, [
|
|
'Content-Type' => 'application/pdf',
|
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
|
]);
|
|
}
|
|
|
|
public function output(): string
|
|
{
|
|
return $this->response->getBody()->getContents();
|
|
}
|
|
}
|
|
|
|
class GotenbergPDFDriver
|
|
{
|
|
public function loadView(string $viewname): GotenbergPDFResponse
|
|
{
|
|
$papersize = explode(' ', config('pdf.connections.gotenberg.papersize'));
|
|
if (count($papersize) != 2) {
|
|
throw new \InvalidArgumentException('Invalid Gotenberg Papersize specified');
|
|
}
|
|
|
|
$host = config('pdf.connections.gotenberg.host');
|
|
|
|
// 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. 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.');
|
|
}
|
|
|
|
$request = Gotenberg::chromium($host)
|
|
->pdf()
|
|
->margins(0, 0, 0, 0) // Margins can be set using CSS
|
|
->paperSize($papersize[0], $papersize[1])
|
|
->html(
|
|
Stream::string(
|
|
'document.html',
|
|
view($viewname)->render(),
|
|
)
|
|
);
|
|
$result = Gotenberg::send($request);
|
|
|
|
return new GotenbergPDFResponse($result);
|
|
}
|
|
}
|