Skip to content

Commit c883416

Browse files
committed
fix(security): read Image() masks through the path allowlist, stop writeHTML() copying CSS url() and img| bullet files to the temp dir, keep the system temp dir out of the markup allowlist, check the image size probe, Annotation() attachments and setHyphenPatterns() paths against the allowlist, convert legacy img| list bullets to list-style-image; require tc-lib-pdf ^8.82.2 (v7.0.20)
1 parent c60c7bd commit c883416

11 files changed

Lines changed: 303 additions & 54 deletions

‎CHANGELOG.TXT‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
7.0.20 (2026-10-24)
2+
- Fixed Image() with a mask handle ($imgmask) reading the image and the mask outside the local path allowlist: both files are now read through the engine file helper, as for a plain Image() call.
3+
- Fixed writeHTML() copying files referenced by CSS url() values and legacy list image bullets into the system temp directory: paths with uppercase characters were copied, without an allowlist check, to a predictable file name readable through the allowlisted temp directory. The copy is removed; the engine resolves these paths directly.
4+
- Resources referenced by HTML, CSS and SVG markup are no longer read from the system temp directory. List the directory in K_ALLOWED_PATHS to allow it. Image(), ImageSVG() and the other API methods keep access to it.
5+
- Image() reports an unknown size for paths outside the allowlist instead of probing the file. Annotation() file attachments and setHyphenPatterns() treat such paths as missing files.
6+
- Fixed writeHTML() ignoring the legacy list-style-type:img|type|width|height|path list bullets: they are converted to list-style-image:url(path). The marker size follows the font size; the width and height fields are ignored.
7+
- Requires `tecnickcom/tc-lib-pdf` ^8.82.1, which applies the path allowlist to PDF import and the host allowlist to signature timestamp requests.
8+
9+
*************************************************
10+
111
7.0.15 (2026-10-03)
212
- Fixed headers and footers drawn with writeHTML() (or other flowing content) in an overridden Header() or Footer(): content placed below the automatic page break margin, such as a footer, was moved to another page. On the last page an extra page was added; on the other pages the footer was drawn at the top of the next page. The graphics state was also left unbalanced (poppler: "Restoring state when no valid states to pop").
313
- Headers and footers are now always drawn on their own page, over the full page width, also on pages with columns. Content that does not fit is clipped at the page edge, as in TCPDF 6.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ configurable via `define()` constants (read by `tcpdf_autoconfig.php`, overridab
244244
only ever *widens* this set — paths are resolved with `realpath()`, so non-existent or
245245
unresolvable entries are silently ignored and traversal/symlink tricks collapse to their
246246
canonical prefix. There is no way to read below the built-in roots.
247+
Resources referenced by HTML, CSS and SVG markup (`writeHTML()`, `writeHTMLCell()`)
248+
use the same list without the system temp directory; list the temp directory in
249+
`K_ALLOWED_PATHS` to allow markup to read from it.
247250

248251
**Remote reads.** Remote URL loading is **off by default** — the single most important
249252
defense against SSRF when rendering untrusted HTML/markup. To opt in, list the exact

‎SECURITY.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The table below describes structural exposure in 6.x. It is not an inventory of
4141
| Class | CWE | Origin in TCPDF 6.x | Handling in TCPDF 7.x |
4242
|---|---|---|---|
4343
| Server-side request forgery | [CWE-918](https://cwe.mitre.org/data/definitions/918.html) | Images, fonts, and other resources are fetched from caller-supplied URLs through cURL and stream functions. | Remote loading is disabled by default; hosts must be listed in `K_ALLOWED_HOSTS`. |
44-
| Path traversal and local file disclosure | [CWE-22](https://cwe.mitre.org/data/definitions/22.html), [CWE-73](https://cwe.mitre.org/data/definitions/73.html) | Filesystem paths supplied by the caller are opened directly for images, fonts, ICC profiles, and templates, and for the `Output()` file destinations. | Local reads, and the `Output()` `F`/`FI`/`FD` destination, are restricted to a `realpath()` allowlist of trusted roots, widened only by `K_ALLOWED_PATHS`. The written file name is sanitized by the engine. |
44+
| Path traversal and local file disclosure | [CWE-22](https://cwe.mitre.org/data/definitions/22.html), [CWE-73](https://cwe.mitre.org/data/definitions/73.html) | Filesystem paths supplied by the caller are opened directly for images, fonts, ICC profiles, and templates, and for the `Output()` file destinations. | Local reads, and the `Output()` `F`/`FI`/`FD` destination, are restricted to a `realpath()` allowlist of trusted roots, widened only by `K_ALLOWED_PATHS`. Image masks follow the same allowlist. Resources referenced by HTML, CSS, and SVG markup use it without the system temp directory. The written file name is sanitized by the engine. |
4545
| Unsafe stream wrappers and object injection | [CWE-502](https://cwe.mitre.org/data/definitions/502.html) | Path arguments reach PHP file APIs, so wrapper schemes such as `phar://` are reachable from caller-controlled strings. | Constrained by the same path allowlist; `setAllowLocalFiles()` no longer widens access. |
4646
| XML external entities and entity expansion | [CWE-611](https://cwe.mitre.org/data/definitions/611.html), [CWE-776](https://cwe.mitre.org/data/definitions/776.html) | SVG input is processed by an XML parser. | SVG parsing is performed by the engine; report against `tc-lib-pdf`. |
4747
| Uncontrolled resource consumption | [CWE-400](https://cwe.mitre.org/data/definitions/400.html), [CWE-674](https://cwe.mitre.org/data/definitions/674.html), [CWE-1333](https://cwe.mitre.org/data/definitions/1333.html) | HTML, CSS, and SVG are parsed with recursive descent and extensive regular expressions over unbounded input. | Parsing is performed by the engine; remote downloads are capped by `K_MAX_REMOTE_SIZE`. |
@@ -69,7 +69,7 @@ Until the upgrade is complete, treat every TCPDF 6.x deployment as an accepted r
6969
TCPDF 7.x is deprecated and receives security fixes only. The mitigations above are defaults, not guarantees. While migration to `tc-lib-pdf` is pending, and in particular when rendering untrusted input:
7070

7171
- Leave `K_ALLOWED_HOSTS` empty unless remote resources are required, and list exact host names when they are.
72-
- Keep `K_ALLOWED_PATHS` as narrow as the deployment allows, and lower `K_MAX_REMOTE_SIZE` to the expected asset size.
72+
- Keep `K_ALLOWED_PATHS` as narrow as the deployment allows, and lower `K_MAX_REMOTE_SIZE` to the expected asset size. Do not list the system temp directory when rendering untrusted markup.
7373
- Use AES encryption (`setProtection()` modes 2 or 3) instead of the legacy RC4 modes.
7474
- Do not build embedded JavaScript, annotations, links, or form actions from unescaped user input.
7575
- Run PDF generation in an isolated process with restricted filesystem and network permissions.

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7.0.19
1+
7.0.20

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"require": {
3030
"php": ">=8.2",
3131
"ext-curl": "*",
32-
"tecnickcom/tc-lib-pdf": "^8.81"
32+
"tecnickcom/tc-lib-pdf": "^8.82"
3333
},
3434
"require-dev": {
3535
"carthage-software/mago": "1.57.0",

‎config/tcpdf_config.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@
226226
* can be read. By default this covers the system temp
227227
* dir, K_PATH_MAIN, the bundled vendor directory, the
228228
* current working directory, K_PATH_FONTS, K_PATH_IMAGES
229-
* and the running script's directory.
229+
* and the running script's directory. Resources referenced
230+
* by HTML, CSS and SVG markup use the same list without
231+
* the system temp dir.
230232
*
231233
* 2. REMOTE FILES - HTTP/HTTPS downloads are DISABLED unless you provide an
232234
* explicit allowlist of trusted host names. This is the
@@ -245,6 +247,8 @@
245247
* Array of absolute path prefixes, merged on top of the built-in defaults
246248
* (the defaults are always kept; this only ever widens local access).
247249
* Entries that do not resolve to an existing directory are ignored.
250+
* Listing the system temp dir also allows HTML, CSS and SVG markup to read
251+
* from it.
248252
*/
249253
//define('K_ALLOWED_PATHS', ['/var/www/shared/assets/']);
250254

‎tcpdf.php‎

Lines changed: 68 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
//============================================================+
44
// File name : tcpdf.php
5-
// Version : 7.0.19
5+
// Version : 7.0.20
66
// Author : Nicola Asuni - Tecnick.com LTD - www.tecnick.com - info@tecnick.com
77
// License : GNU-LGPL v3 (https://www.gnu.org/copyleft/lesser.html)
88
// Copyright (C): 2002-2026 Nicola Asuni - Tecnick.com LTD
@@ -15,7 +15,7 @@
1515
* See: https://tcpdf.org
1616
* @package com.tecnick.tcpdf
1717
* @author Nicola Asuni
18-
* @version 7.0.19
18+
* @version 7.0.20
1919
*/
2020

2121
// TCPDF configuration
@@ -538,6 +538,33 @@ protected function fileAllowedPaths(): array
538538
return array_values(array_unique($paths));
539539
}
540540

541+
/**
542+
* Local paths resources referenced by HTML, CSS and SVG markup may be read
543+
* from: the fileAllowedPaths() entries without the system temp directory,
544+
* unless K_ALLOWED_PATHS lists that directory.
545+
*
546+
* @return array<int, string>
547+
*/
548+
protected function fileMarkupAllowedPaths(): array
549+
{
550+
$paths = $this->fileAllowedPaths();
551+
$temp = realpath(sys_get_temp_dir());
552+
if ($temp === false) {
553+
return $paths;
554+
}
555+
556+
// @mago-expect analysis:redundant-logical-operation
557+
if (defined('K_ALLOWED_PATHS') && is_array(K_ALLOWED_PATHS)) {
558+
foreach (K_ALLOWED_PATHS as $extrapath) {
559+
if (is_string($extrapath) && $extrapath !== '' && realpath($extrapath) === $temp) {
560+
return $paths;
561+
}
562+
}
563+
}
564+
565+
return array_values(array_filter($paths, static fn(string $path): bool => $path !== $temp));
566+
}
567+
541568
/**
542569
* Assemble the file-access options handed to the engine's shared file
543570
* helper (tc-lib-file). These drive the upstream security sandbox that
@@ -546,6 +573,8 @@ protected function fileAllowedPaths(): array
546573
*
547574
* The mapping is configuration-driven:
548575
* - allowedPaths <- built-in defaults + K_ALLOWED_PATHS (local reads)
576+
* - markupAllowedPaths <- allowedPaths without the system temp directory
577+
* (local reads triggered by HTML, CSS and SVG markup)
549578
* - allowedHosts <- K_ALLOWED_HOSTS (remote HTTP/HTTPS reads; empty
550579
* disables remote loading entirely — the safe default)
551580
* - maxRemoteSize <- K_MAX_REMOTE_SIZE (byte cap on remote downloads)
@@ -556,14 +585,18 @@ protected function fileAllowedPaths(): array
556585
*
557586
* @return array{
558587
* allowedPaths: array<int, string>,
588+
* markupAllowedPaths: array<int, string>,
559589
* allowedHosts?: array<int, string>,
560590
* maxRemoteSize?: int,
561591
* curlopts?: array<int, bool|int|string>,
562592
* }
563593
*/
564594
protected function fileOptions(): array
565595
{
566-
$options = ['allowedPaths' => $this->fileAllowedPaths()];
596+
$options = [
597+
'allowedPaths' => $this->fileAllowedPaths(),
598+
'markupAllowedPaths' => $this->fileMarkupAllowedPaths(),
599+
];
567600

568601
// Remote URL loading is disabled by default in the upstream library:
569602
// an empty (or unset) host allowlist keeps it disabled. Populate
@@ -1997,6 +2030,14 @@ protected function resolveLocalFile(string $file): string
19972030
return $real === false ? $file : $real;
19982031
}
19992032

2033+
/**
2034+
* Whether a local file exists inside the engine path allowlist.
2035+
*/
2036+
protected function isAllowedLocalFile(string $file): bool
2037+
{
2038+
return $file !== '' && $this->engine()->file->isAllowedFile($file) && is_file($file);
2039+
}
2040+
20002041
/**
20012042
* Register an external link annotation for the given rectangle.
20022043
*/
@@ -3397,7 +3438,7 @@ public function Annotation($_x, $_y, $_w, $_h, $_text, $_opt = ['Subtype' => 'Te
33973438
$key = strtolower((string) $key);
33983439
if ($key === 'fs' && is_string($val)) {
33993440
$val = $this->resolveLocalFile($val);
3400-
if (!is_file($val)) {
3441+
if (!$this->isAllowedLocalFile($val)) {
34013442
// The legacy implementation silently skipped attachments
34023443
// whose source file cannot be read.
34033444
return;
@@ -4453,8 +4494,9 @@ public function Image(
44534494
* alpha channel, then return the merged PNG content. The engine's import
44544495
* path then splits that alpha back out into a /SMask (the only soft-mask
44554496
* mechanism tc-lib-pdf-image exposes), reproducing legacy's separate-mask
4456-
* Image() call. Returns null when the images cannot be read (caller falls
4457-
* back to the unmasked file).
4497+
* Image() call. Both files are read through the engine file helper, so the
4498+
* path allowlist applies. Returns null when the images cannot be read
4499+
* (caller falls back to the unmasked file).
44584500
*
44594501
* Mask polarity follows the PDF soft-mask convention used by legacy: a
44604502
* white mask pixel is fully opaque, a black one fully transparent.
@@ -4465,13 +4507,15 @@ protected function mergeImageAlphaMask(string $basefile, string $maskfile): ?str
44654507
return null;
44664508
}
44674509

4468-
if (!is_readable($basefile) || !is_readable($maskfile)) {
4510+
$filehelper = $this->engine()->file;
4511+
try {
4512+
$basedata = $filehelper->getLocalFileData($basefile);
4513+
$maskdata = $filehelper->getLocalFileData($maskfile);
4514+
} catch (\Throwable) {
44694515
return null;
44704516
}
44714517

4472-
$basedata = file_get_contents($basefile);
4473-
$maskdata = file_get_contents($maskfile);
4474-
if ($basedata === false || $maskdata === false) {
4518+
if (!is_string($basedata) || $basedata === '' || !is_string($maskdata) || $maskdata === '') {
44754519
return null;
44764520
}
44774521

@@ -4523,6 +4567,7 @@ protected function mergeImageAlphaMask(string $basefile, string $maskfile): ?str
45234567

45244568
/**
45254569
* Return the pixel size of an image file or '@'-prefixed data stream.
4570+
* Files outside the path allowlist report an unknown size.
45264571
*
45274572
* @return array{0: int, 1: int} Width and height in pixels (0 when unknown).
45284573
*/
@@ -4531,7 +4576,7 @@ protected function imagePixelSize(string $file): array
45314576
if ($file !== '' && $file[0] === '@') {
45324577
$info = getimagesizefromstring(substr($file, 1));
45334578
} else {
4534-
$info = is_file($file) ? getimagesize($file) : false;
4579+
$info = $this->isAllowedLocalFile($file) ? getimagesize($file) : false;
45354580
}
45364581

45374582
return is_array($info) ? [(int) $info[0], (int) $info[1]] : [0, 0];
@@ -7516,28 +7561,6 @@ protected function absolutizeMarkupPath(string $source): string
75167561
return $real === false ? $source : $real;
75177562
}
75187563

7519-
/**
7520-
* Return a markup resource path that survives CSS lowercasing.
7521-
*
7522-
* CSS values are lowercased by the engine parser, so paths used in CSS
7523-
* context are copied to a lowercase temporary file when necessary.
7524-
*/
7525-
protected function cssSafeMarkupPath(string $source): string
7526-
{
7527-
$source = $this->absolutizeMarkupPath($source);
7528-
if ($source === strtolower($source) || !is_file($source)) {
7529-
return $source;
7530-
}
7531-
7532-
$ext = strtolower(pathinfo($source, PATHINFO_EXTENSION));
7533-
$copy = rtrim(sys_get_temp_dir(), '/') . '/tcpdf_css_' . md5($source) . ($ext !== '' ? '.' . $ext : '');
7534-
if (!is_file($copy)) {
7535-
copy($source, $copy);
7536-
}
7537-
7538-
return $copy;
7539-
}
7540-
75417564
/**
75427565
* Flatten styled inline elements that contain nested inline children.
75437566
*
@@ -7640,8 +7663,10 @@ static function (array $match): string {
76407663
* - vertical align attributes are dropped from images inside tables
76417664
* (the engine would overlap them with the row borders);
76427665
* - '@'-prefixed base64 image data is rewritten to a data URI;
7643-
* - relative local image paths are made absolute (src attributes,
7644-
* CSS url() values and the legacy img| list-bullet syntax);
7666+
* - relative local image paths are made absolute (src attributes and
7667+
* CSS url() values);
7668+
* - the legacy list-style-type:img|type|width|height|path bullet syntax
7669+
* becomes list-style-image:url(path);
76457670
* - legacy page anchors (href="#N" or href="#N,y") become internal
76467671
* links to page N at ordinate y;
76477672
* - malformed hexadecimal colors, silently coerced by the legacy
@@ -7692,13 +7717,16 @@ protected function normalizeHtmlMarkup(string $html): string
76927717
);
76937718
$html = (string) preg_replace_callback(
76947719
'/url\(["\']?([^"\')]+)["\']?\)/',
7695-
fn(array $match): string => "url('" . $this->cssSafeMarkupPath($match[1] ?? '') . "')",
7720+
fn(array $match): string => "url('" . $this->absolutizeMarkupPath($match[1] ?? '') . "')",
76967721
$html,
76977722
);
7698-
// Legacy custom list bullet syntax: list-style-type:img|ext|w|h|path
7723+
// Legacy custom list bullet syntax: list-style-type:img|ext|w|h|path.
7724+
// The engine sizes list-style-image markers from the font size.
76997725
$html = (string) preg_replace_callback(
7700-
'/(img\|[^|;"\'<>]*\|[^|;"\'<>]*\|[^|;"\'<>]*\|)([^;"\'<>]+)/',
7701-
fn(array $match): string => ($match[1] ?? '') . $this->cssSafeMarkupPath($match[2] ?? ''),
7726+
'/list-style-type\s*:\s*img\|[^|;"\'<>]*\|[^|;"\'<>]*\|[^|;"\'<>]*\|([^;"\'<>]+)/i',
7727+
fn(array $match): string => (
7728+
"list-style-image:url('" . $this->absolutizeMarkupPath(trim($match[1] ?? '')) . "')"
7729+
),
77027730
$html,
77037731
);
77047732

@@ -8519,7 +8547,7 @@ public function hyphenateText(
85198547
}
85208548

85218549
$file = $this->resolveLocalFile($_patterns);
8522-
if (is_file($file)) {
8550+
if ($this->isAllowedLocalFile($file)) {
85238551
$eng->setTexHyphenPatterns($eng->loadTexHyphenPatterns($file));
85248552
}
85258553
} elseif (is_array($_patterns) && $_patterns !== []) {

‎tcpdf_autoconfig.php‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,8 @@
297297
// directory, K_PATH_FONTS, K_PATH_IMAGES and the running script directory).
298298
// The built-in defaults are always included; this only ever widens access.
299299
// Must be defined before this file is loaded. Entries that do not resolve to
300-
// an existing directory are ignored.
300+
// an existing directory are ignored. Listing the system temp dir also allows
301+
// HTML, CSS and SVG markup to read from it.
301302
if (!defined('K_ALLOWED_PATHS')) {
302303
define('K_ALLOWED_PATHS', []);
303304
}

0 commit comments

Comments
 (0)