Skip to content

Security and operations

This bridge sends your Hypertext Markup Language (HTML) across a network boundary to a browser engine. This page documents the controls that protect that boundary, using the source as the point of truth. When a control cites a standard, the citation is the one declared in the code docblock. This page restates the code’s assertion; it does not reconstruct normative wording.

The package docblocks name the threats it defends against:

  • XSS-to-PDF — Cross-site scripting (XSS) through hostile markup that runs during Portable Document Format (PDF) rendering.
  • SSRF — Server-side request forgery (SSRF) caused by markup or a destination Uniform Resource Locator (URL) that sends a request to an internal address.
  • Resource exhaustion — Oversized input or a decompression bomb.
  • DNS rebinding — Domain Name System (DNS) rebinding, where a hostname passes validation and then resolves to a private address at connection time.
  • On-path TLS interception — On-path Transport Layer Security (TLS) interception through a substituted certificate on the path to the Worker.

Each threat has a specific, testable control below.

Input controls (before the request leaves PHP)

Section titled “Input controls (before the request leaves PHP)”

CloudflareSecurityPolicy::validate() runs before any request is built:

ControlBehaviorLimit source
Size capRejects HTML larger than maxHtmlSizeCloudflareRendererConfig, default 5000000 bytes
Base64 decompression-bomb guardEstimates the decoded size of every data:…;base64,… URI; rejects values at or above the ceilingMAX_DATA_URI_BYTES = 13631488
Meta-refresh banRejects any <meta http-equiv="refresh">, case-insensitivelyregex in CloudflareSecurityPolicy

A violation raises RuntimeException with a message that names the offending value and the limit. The meta-refresh ban exists because a refresh directive can start navigation inside the page the Worker renders — an SSRF vector that lives in content, not in the URL.

The HTML security policy from nextpdf/core (HtmlSecurityPolicyInterface, default DefaultHtmlSecurityPolicy) runs at the parse layer and complements the transport-layer checks above. Retrieve it with getHtmlSecurityPolicy(). Inject a custom one through the constructor.

Destination controls (SSRF and DNS rebinding)

Section titled “Destination controls (SSRF and DNS rebinding)”

CloudflareSecurityPolicy::validateWorkerUrl():

  1. Rejects a URL that fails to parse or lacks a scheme/host (Invalid Worker URL).
  2. Rejects any scheme other than HTTPS (Worker URL must use HTTPS).
  3. For an IP-literal host, rejects private or reserved ranges with PHP’s FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. In practice, this rejects RFC 1918 private space, loopback, and RFC 3927 link-local addresses. Tests explicitly cover 192.168.x, 127.0.0.1, and 169.254.x rejections. PHP’s filter extension decides range membership; this package does not pin that decision to a clause. RFC 1918 and RFC 3927 are named here descriptively as the well-known definitions of those ranges.
  4. For a hostname, resolves all A and AAAA records with dns_get_record() (not gethostbyname(), which returns only the first answer) and rejects the host if any resolved address is private or reserved.

The use of all-records resolution is deliberate. The class docblock documents it as a defense against a host that returns several records, where a single-record lookup might choose the public address while the later connection chooses a private one. This matches the OWASP SSRF Prevention Cheat Sheet: resolve both A and AAAA answers for the domain and apply the non-public-address check to the full result set.

validateWorkerUrl() returns the vetted IP set. Immediately before sending, the renderer calls assertPinsStillValid(). That call re-resolves the host and rejects a newly seen IP (Worker URL DNS answer changed since validation — possible DNS rebinding attack). This closes the time-of-check / time-of-use window between validation and connection.

When a vetted IP set or a Subject Public Key Info (SPKI) pin set is present and a PHP Standards Recommendation 17 (PSR-17) ResponseFactory was supplied, the renderer uses Transport\PinnedCurlTransport instead of the injected PHP Standards Recommendation 18 (PSR-18) client. The transport enforces these controls at the cURL handle layer:

  • Pinned DNSCURLOPT_RESOLVE binds the host:port to the vetted IP set, so libcurl does not perform its own lookup at connect time. This binding makes the userland DNS check apply to the actual connection; without it, libcurl could resolve a different address.
  • TLS public-key pinningCURLOPT_PINNEDPUBLICKEY is set from the combined pin set. This follows RFC 7469 §2.6: a pinned connection is accepted when the server-presented SPKI fingerprint set intersects the configured pin set, and a pin-validation failure is non-recoverable. Pin strings are normalized from sha256/<base64> to cURL’s sha256//<base64> form; a malformed pin raises InvalidSpkiPinException.
  • TLS verification onCURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2.
  • No automatic redirectsCURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0. A 3xx response is surfaced to the policy layer instead of being followed by libcurl to an unvetted host. The class docblock states that this is deliberate, so redirects are re-validated instead of silently followed.
  • Hard timeoutCURLOPT_TIMEOUT is set from renderTimeout (default 30 seconds).

A cURL error or non-string body raises CloudflareRenderException with the cURL error number and message.

The configuration carries pinnedPublicKeys and a separate backupPublicKeys. RFC 7469 §2.5 describes a backup pin as a fingerprint for a secondary, not-yet-deployed key pair kept offline, and treats it as the primary recovery path for inadvertent pin-validation failure. Keep at least one backup pin so certificate rotation does not brick the endpoint. The separate field lets you validate a rotation independently. Operationally:

  • Pin the SPKI of the leaf or of an intermediate whose rotation you control.
  • Always configure a backup pin for the next certificate before rotating.
  • An empty pin set disables pinning; use that only with a stable, known certificate chain. Pinning is opt-in by configuration.
  • The Worker request carries Authorization: Bearer <apiToken>. apiToken is #[SensitiveParameter], so stack traces redact it. The reachability probe sends the same bearer header on a Hypertext Transfer Protocol (HTTP) HEAD.
  • Cloudflare R2 access keys (accessKeyId, secretAccessKey) are #[SensitiveParameter] and used only to derive the Amazon Web Services (AWS) Signature V4 signing key.
  • ApiKeyValidator compares keys with hash_equals() (timing-safe) and supports Secure Hash Algorithm 256 (SHA-256) hashed-key storage via validateHashed().
  • Configuration objects are final readonly — a secret set once cannot be mutated.
  • Source secrets from environment variables or a secrets manager. Never commit them. The package follows the wider NextPDF security baseline: PHPStan Level 10, declare(strict_types=1) on every file, no eval()/exec(), GitHub Actions pinned to SHA.
  • It states no Cloudflare platform limit (Worker CPU time, memory, request body ceiling, or subrequest count). The only size and time limits this documentation states are the ones the package enforces itself, listed above and in /integrations/cloudflare/configuration/. For platform limits, consult Cloudflare’s official documentation and your Worker’s own implementation.
  • It does not sign PDFs and makes no signature-conformance claim. When signatures are required, render here, then sign with the engine. NextPDF Pro provides PDF Advanced Electronic Signatures (PAdES) B-B signing only; long-term-validation profiles are an Enterprise capability and are out of scope for this bridge.
  • It does not certify, guarantee, or render the pipeline “tamper-proof”. It implements only the specific, source-verifiable controls described on this page.
SymptomFirst check
Worker URL must use HTTPSCheck the configured workerUrl scheme.
private or reserved IPThe Worker hostname’s DNS records; look for a record resolving into RFC 1918 / loopback / RFC 3927 space.
DNS answer changed since validationDNS instability or a rebinding attempt; re-resolve and inspect the full record set.
cURL transport errorThe network path, TLS chain, and — if pins are set — whether the served certificate’s SPKI is still in the pin set.
Renders fail right after a cert rotationA pin set without a matching backup pin. Add the new SPKI as a backup before rotating.
is not installed / no LocalRendererFactoryInterfaceFallback is enabled but no factory is wired, or nextpdf/artisan is absent.
Rate limit denials inconsistent across nodesThe in-memory limiter is per-process; front it with a shared store.

Report vulnerabilities through GitHub Security Advisories or the security contact in the repository SECURITY.md. Do not file security issues as public GitHub issues.

  • /integrations/cloudflare/overview/ — why this package is shaped around the boundary.
  • /integrations/cloudflare/configuration/ — pin-set and limit fields.
  • /integrations/cloudflare/troubleshooting/ — full failure-to-exception mapping.