PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Services/PdfService.php +378 -5 3.0.2.9 → 3.0.16 View file →
@@ -14,10 +14,16 @@
14 14 public function renderHtmlToPdf(string $html, array $options = []): string
15 15 {
16 16 $paper = (string) ($options['paper'] ?? 'A4');
17 17 $orientation = (string) ($options['orientation'] ?? 'portrait');
18 - $defaultFont = (string) ($options['default_font'] ?? 'DejaVu Sans');
19 18
19 + // Resolve the default font with awareness of the site's locale,
20 + // so a Nepali / Hindi / Arabic / CJK install gets a font that
21 + // can actually render the script — Dompdf's bundled DejaVu Sans
22 + // only covers Latin/Greek/Cyrillic, so non-Latin glyphs come
23 + // out as blank rectangles otherwise. See `resolveDefaultFont()`.
24 + $defaultFont = (string) ($options['default_font'] ?? $this->resolveDefaultFont());
25 +
20 26 if (class_exists('Dompdf\\Dompdf')) {
21 27 $optionsClass = 'Dompdf\\Options';
22 28 $dompdfClass = 'Dompdf\\Dompdf';
23 29
@@ -22,14 +28,92 @@
22 28 $dompdfClass = 'Dompdf\\Dompdf';
23 29
24 30 $dompdfOptions = class_exists($optionsClass) ? new $optionsClass() : null;
25 31 if ($dompdfOptions) {
26 - $dompdfOptions->set('isRemoteEnabled', true);
32 + // Remote loading is required so PDFs can render the site logo / trip images.
33 + // Filter exists so site owners can lock it down to the local filesystem if they
34 + // never use remote images and want to fully eliminate SSRF risk.
35 + $remoteEnabled = (bool) apply_filters('yatra_pdf_remote_enabled', true);
36 + $dompdfOptions->set('isRemoteEnabled', $remoteEnabled);
27 37 $dompdfOptions->set('isHtml5ParserEnabled', true);
28 38 $dompdfOptions->set('defaultFont', $defaultFont);
39 +
40 + // Enable `<script type="text/php">` blocks in PDF
41 + // templates. Required so the itinerary template can
42 + // draw a per-page header that's skipped on page 1
43 + // (Dompdf's CSS `position: fixed` runs unconditionally
44 + // on every page; a PHP canvas script is the only way
45 + // to gate by `$PAGE_NUM`). The PHP runs in Dompdf's
46 + // sandbox with access to the $pdf, $fontMetrics,
47 + // $PAGE_NUM and $PAGE_COUNT variables only — the
48 + // template files we ship are plugin-controlled so
49 + // this isn't an attack surface like user-supplied
50 + // HTML would be.
51 + $dompdfOptions->set('isPhpEnabled', true);
52 +
53 + // Move the font cache out of the plugin's vendor/ tree
54 + // (which is normally read-only on managed hosts and gets
55 + // wiped by composer install). Using
56 + // wp-content/uploads/yatra-pdf-fonts/cache/ keeps the
57 + // generated .ufm metric files persistent across deploys
58 + // AND ensures Dompdf can actually write — without write
59 + // access registerFont() silently no-ops, the Devanagari
60 + // font never gets installed, and Nepali glyphs render
61 + // as missing-glyph rectangles.
62 + $cacheDir = $this->ensureWritableFontCacheDir();
63 + if ($cacheDir) {
64 + $dompdfOptions->set('fontDir', $cacheDir);
65 + $dompdfOptions->set('fontCache', $cacheDir);
66 + }
67 +
68 + // Restrict file:// reads to within ABSPATH so a crafted template can't read /etc/passwd
69 + // or other files outside the WordPress install.
70 + if (defined('ABSPATH')) {
71 + $dompdfOptions->set('chroot', [ABSPATH]);
72 + }
73 +
74 + // Block file:// and php:// protocols from remote requests
75 + // (only http/https for network fetches). The empty-string
76 + // key whitelists LOCAL file PATHS (no URL scheme) —
77 + // Dompdf's Helpers::explode_url() returns "" as the
78 + // protocol for a bare absolute path. Without that key,
79 + // FontMetrics::registerFont() silently rejects every
80 + // local TTF we pass to it, which is exactly why the
81 + // bundled Noto Sans Devanagari fonts never installed and
82 + // Nepali text fell back to DejaVu missing-glyph
83 + // rectangles. file paths are NOT the same as `file://`
84 + // URLs and don't expose any SSRF surface.
85 + $dompdfOptions->set('allowedProtocols', [
86 + '' => ['rules' => []],
87 + 'http://' => ['rules' => []],
88 + 'https://' => ['rules' => []],
89 + ]);
90 +
91 + // SSRF hardening for remote fetches: short timeout + identifying User-Agent so admins
92 + // can spot dompdf traffic in logs. Does not stop SSRF on its own — sites that do not
93 + // need remote images should disable via the `yatra_pdf_remote_enabled` filter above.
94 + if ($remoteEnabled) {
95 + $httpContext = stream_context_create([
96 + 'http' => [
97 + 'timeout' => 5,
98 + 'follow_location' => 0,
99 + 'user_agent' => 'YatraPDF/' . (defined('YATRA_VERSION') ? YATRA_VERSION : '1.0'),
100 + ],
101 + 'ssl' => [
102 + 'verify_peer' => true,
103 + 'verify_peer_name' => true,
104 + ],
105 + ]);
106 + $dompdfOptions->setHttpContext($httpContext);
107 + }
29 108 }
30 109
31 110 $dompdf = $dompdfOptions ? new $dompdfClass($dompdfOptions) : new $dompdfClass();
111 +
112 + // Register any user-supplied Unicode fonts BEFORE loadHtml so
113 + // CSS `font-family` references resolve correctly.
114 + $this->registerExtraFonts($dompdf);
115 +
32 116 $dompdf->loadHtml($html, 'UTF-8');
33 117 $dompdf->setPaper($paper, $orientation);
34 118 $dompdf->render();
35 119 return (string) $dompdf->output();
@@ -44,8 +128,276 @@
44 128
45 129 throw new \RuntimeException('PDF engine is not available');
46 130 }
47 131
132 + /**
133 + * Decide which font family to hand to Dompdf as `defaultFont` based
134 + * on the current WordPress locale. Dompdf's only bundled Unicode
135 + * font is DejaVu Sans, which covers Latin/Greek/Cyrillic; for other
136 + * scripts the site has to ship its own TTF (see
137 + * `registerExtraFonts()`).
138 + *
139 + * The mapping intentionally aims at FAMILIES the typical
140 + * `yatra_pdf_extra_fonts` install would expose (Noto Sans <script>
141 + * is the canonical pick), so when an admin drops the matching font
142 + * file into the fonts dir the chain "just works". The CSS in the
143 + * PDF templates ALSO carries these names as fallbacks, so
144 + * Dompdf's per-glyph font picker can pick whichever family is
145 + * actually registered.
146 + *
147 + * Filter `yatra_pdf_default_font` lets a site override the
148 + * resolved family entirely.
149 + */
150 + private function resolveDefaultFont(): string
151 + {
152 + $locale = function_exists('determine_locale') ? determine_locale() : (function_exists('get_locale') ? get_locale() : 'en_US');
153 + $lang = strtolower(substr((string) $locale, 0, 2));
154 +
155 + $devanagari = ['ne', 'hi', 'mr', 'sa']; // Nepali, Hindi, Marathi, Sanskrit
156 + $arabic = ['ar', 'fa', 'ur'];
157 + $cjk = ['zh', 'ja', 'ko'];
158 +
159 + if (in_array($lang, $devanagari, true)) {
160 + $font = 'Noto Sans Devanagari';
161 + } elseif (in_array($lang, $arabic, true)) {
162 + $font = 'Noto Sans Arabic';
163 + } elseif (in_array($lang, $cjk, true)) {
164 + $font = 'Noto Sans CJK';
165 + } else {
166 + $font = 'DejaVu Sans';
167 + }
168 +
169 + return (string) apply_filters('yatra_pdf_default_font', $font, $locale);
170 + }
171 +
172 + /**
173 + * Parse a TTF/OTF basename into (cssFamily, style).
174 + *
175 + * Examples:
176 + * "NotoSansDevanagari-Regular" → ("Noto Sans Devanagari", "normal")
177 + * "NotoSansDevanagari-Bold" → ("Noto Sans Devanagari", "bold")
178 + * "NotoSansArabic-Italic" → ("Noto Sans Arabic", "italic")
179 + * "NotoSansCJK-BoldItalic" → ("Noto Sans CJK", "bold_italic")
180 + * "Roboto" → ("Roboto", "normal")
181 + * "Noto Sans Devanagari-Bold" → ("Noto Sans Devanagari", "bold") (file already has spaces)
182 + *
183 + * Returns `["", ""]` when the basename can't yield a sensible family.
184 + *
185 + * The CSS family is derived by:
186 + * 1. Stripping any recognised weight/style suffix (Regular, Bold,
187 + * Italic, BoldItalic, Oblique, BoldOblique).
188 + * 2. Replacing underscores with spaces.
189 + * 3. If the result is PascalCase with no separators, inserting a
190 + * space before each interior capital letter — so the bundled
191 + * `NotoSansDevanagari` file matches the CSS `"Noto Sans
192 + * Devanagari"` declaration in the templates.
193 + *
194 + * @return array{0:string,1:string}
195 + */
196 + private function parseFontFilename(string $base): array
197 + {
198 + $style = 'normal';
199 + $family = $base;
200 +
201 + if (preg_match('/^(.+?)[-_](Regular|Bold(?:Italic|Oblique)?|Italic|Oblique)$/i', $base, $m)) {
202 + $family = $m[1];
203 + $variant = strtolower($m[2]);
204 + if ($variant === 'bolditalic' || $variant === 'boldoblique') {
205 + $style = 'bold_italic';
206 + } elseif ($variant === 'bold') {
207 + $style = 'bold';
208 + } elseif ($variant === 'italic' || $variant === 'oblique') {
209 + $style = 'italic';
210 + } else {
211 + // Regular / unknown → normal weight.
212 + $style = 'normal';
213 + }
214 + }
215 +
216 + // Normalise underscores → spaces first so a file already named
217 + // "Noto_Sans_Devanagari-Bold.ttf" lands as "Noto Sans Devanagari".
218 + $cssFamily = str_replace('_', ' ', $family);
219 +
220 + // PascalCase → spaced (insert a space between a lowercase and the
221 + // next uppercase, and between two uppercases followed by a
222 + // lowercase — preserves "CJK" inside "Noto Sans CJK").
223 + if (strpos($cssFamily, ' ') === false) {
224 + $cssFamily = preg_replace('/([a-z])([A-Z])/', '$1 $2', $cssFamily);
225 + $cssFamily = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1 $2', (string) $cssFamily);
226 + }
227 +
228 + $cssFamily = trim((string) $cssFamily);
229 +
230 + return [$cssFamily, $style];
231 + }
232 +
233 + /**
234 + * Resolve a writable font cache directory under wp-content/uploads/
235 + * — required so Dompdf's `registerFont()` can write the generated
236 + * `.ufm` metric files and the copied TTF. Returns the absolute path
237 + * on success, or an empty string when uploads aren't writable (in
238 + * which case the caller falls back to Dompdf's vendor cache).
239 + */
240 + private function ensureWritableFontCacheDir(): string
241 + {
242 + $uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null;
243 + if (!$uploads || empty($uploads['basedir'])) {
244 + return '';
245 + }
246 + $dir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts/cache';
247 + if (!is_dir($dir)) {
248 + if (function_exists('wp_mkdir_p')) {
249 + wp_mkdir_p($dir);
250 + } else {
251 + @mkdir($dir, 0755, true);
252 + }
253 + }
254 + if (!is_dir($dir) || !is_writable($dir)) {
255 + return '';
256 + }
257 +
258 + // One-time cache invalidation. Tied to the plugin version so it
259 + // re-runs whenever the bundled fonts or the registerFont logic
260 + // change — for example the `allowedProtocols` fix that finally
261 + // lets registerFont() write font cache for local TTFs:
262 + // installations that rendered a PDF BEFORE that fix have a
263 + // partial `installed-fonts.json` recording the failed bold
264 + // entry, and Dompdf's runtime cache trusts that file even when
265 + // the matching .ufm is missing. Wiping the dir once per
266 + // version forces a fresh build with the corrected logic.
267 + $version = defined('YATRA_VERSION') ? (string) constant('YATRA_VERSION') : '1.0.0';
268 + $stampFile = $dir . '/.yatra-font-cache-version';
269 + $currentStamp = is_file($stampFile) ? (string) @file_get_contents($stampFile) : '';
270 + if ($currentStamp !== $version) {
271 + foreach ((array) glob($dir . '/*') as $file) {
272 + if (is_file($file)) {
273 + @unlink($file);
274 + }
275 + }
276 + @file_put_contents($stampFile, $version);
277 + }
278 +
279 + return $dir;
280 + }
281 +
282 + /**
283 + * Register any TTF/OTF the site has dropped into the Yatra PDF
284 + * fonts dir with Dompdf's `FontMetrics`. Without this Dompdf has
285 + * no idea those files exist — even with a matching CSS
286 + * `font-family`, glyphs from the file would never reach the PDF.
287 + *
288 + * Default font dir is `wp-content/uploads/yatra-pdf-fonts/`
289 + * (filter `yatra_pdf_fonts_dir`), so admins can add fonts without
290 + * touching plugin code. Any TTF/OTF whose filename matches
291 + * `<Family>[-<Style>].ttf` is registered as that family/style —
292 + * style suffix is one of `Regular`, `Bold`, `Italic`,
293 + * `BoldItalic`. Unknown suffixes default to `normal`.
294 + *
295 + * The filter `yatra_pdf_extra_fonts` lets code register fonts
296 + * imperatively too, returning an array of
297 + * [ family => [ 'normal' => '/abs/path.ttf', 'bold' => ... ] ]
298 + */
299 + private function registerExtraFonts($dompdf): void
300 + {
301 + $metrics = method_exists($dompdf, 'getFontMetrics') ? $dompdf->getFontMetrics() : null;
302 + if (!$metrics) {
303 + return;
304 + }
305 +
306 + // Scan order: plugin-bundled fonts (always present) first, then
307 + // the user-overridable uploads dir. Later registrations win so
308 + // a site can drop in a higher-quality Devanagari font in
309 + // uploads/yatra-pdf-fonts/ to override the bundled Noto Sans.
310 + $dirs = [];
311 + if (defined('YATRA_PLUGIN_PATH')) {
312 + $bundled = rtrim((string) YATRA_PLUGIN_PATH, '/\\') . '/assets/pdf-fonts';
313 + if (is_dir($bundled)) {
314 + $dirs[] = $bundled;
315 + }
316 + }
317 + $uploads = function_exists('wp_get_upload_dir') ? wp_get_upload_dir() : null;
318 + if ($uploads && !empty($uploads['basedir'])) {
319 + $userDir = rtrim((string) $uploads['basedir'], '/\\') . '/yatra-pdf-fonts';
320 + $userDir = (string) apply_filters('yatra_pdf_fonts_dir', $userDir);
321 + if ($userDir && is_dir($userDir)) {
322 + $dirs[] = $userDir;
323 + }
324 + }
325 +
326 + $fonts = [];
327 +
328 + foreach ($dirs as $dir) {
329 + foreach ((array) glob($dir . '/*.{ttf,TTF,otf,OTF}', GLOB_BRACE) as $file) {
330 + $base = pathinfo((string) $file, PATHINFO_FILENAME);
331 + [$cssFamily, $style] = $this->parseFontFilename($base);
332 + if ($cssFamily === '') {
333 + continue;
334 + }
335 + if (!isset($fonts[$cssFamily])) {
336 + $fonts[$cssFamily] = [];
337 + }
338 + $fonts[$cssFamily][$style] = $file;
339 + }
340 + }
341 +
342 + $fonts = (array) apply_filters('yatra_pdf_extra_fonts', $fonts);
343 +
344 + // Map our internal style keys to Dompdf's
345 + // ['weight' => …, 'style' => …] tuple, since registerFont()
346 + // uses CSS-shaped style arrays (weight = normal|bold,
347 + // style = normal|italic) and not the four-key family entries.
348 + $styleMatrix = [
349 + 'normal' => ['weight' => 'normal', 'style' => 'normal'],
350 + 'bold' => ['weight' => 'bold', 'style' => 'normal'],
351 + 'italic' => ['weight' => 'normal', 'style' => 'italic'],
352 + 'bold_italic' => ['weight' => 'bold', 'style' => 'italic'],
353 + ];
354 +
355 + foreach ($fonts as $family => $styles) {
356 + if (!is_array($styles)) {
357 + continue;
358 + }
359 + // Fill missing styles with the regular variant so Dompdf
360 + // doesn't fall back to its own default on bold/italic.
361 + $normal = $styles['normal'] ?? reset($styles);
362 + if (!$normal) {
363 + continue;
364 + }
365 + foreach (array_keys($styleMatrix) as $style) {
366 + if (empty($styles[$style])) {
367 + $styles[$style] = $normal;
368 + }
369 + }
370 +
371 + foreach ($styleMatrix as $key => $css) {
372 + $file = $styles[$key] ?? null;
373 + if (!$file || !is_file($file)) {
374 + continue;
375 + }
376 + try {
377 + // registerFont downloads (or here, copies from a
378 + // local path) the TTF into Dompdf's font dir, then
379 + // generates the .ufm metrics file and wires the
380 + // family/weight/style triple into the FontMetrics
381 + // lookup table — all in one call. Passing a local
382 + // path works because Helpers::getFileContent() just
383 + // file_get_contents()s it.
384 + $metrics->registerFont(
385 + [
386 + 'family' => (string) $family,
387 + 'weight' => $css['weight'],
388 + 'style' => $css['style'],
389 + ],
390 + $file
391 + );
392 + } catch (\Throwable $e) {
393 + // Don't take the whole PDF render down for one bad font.
394 + error_log('[Yatra] Failed to register PDF font ' . $family . ' (' . $key . '): ' . $e->getMessage());
395 + }
396 + }
397 + }
398 + }
399 +
48 400 public function renderHtmlToPdfSafely(string $html, array $options = []): string
49 401 {
50 402 $originalErrorReporting = error_reporting();
51 403 $originalDisplayErrors = ini_get('display_errors');
@@ -92,10 +444,18 @@
92 444 if (!file_exists($templateFile)) {
93 445 throw new \InvalidArgumentException("Template file not found: {$templateFile}");
94 446 }
95 447
96 - // Extract data variables for template
97 - extract($data, EXTR_OVERWRITE);
448 + // Defence in depth: only extract string-keyed entries with valid PHP-identifier names,
449 + // and skip keys that would clobber locals already in scope (EXTR_SKIP). Stops a malicious
450 + // $data array from injecting arbitrary local variables into the template scope.
451 + $safeData = [];
452 + foreach ($data as $key => $value) {
453 + if (is_string($key) && preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $key)) {
454 + $safeData[$key] = $value;
455 + }
456 + }
457 + extract($safeData, EXTR_SKIP);
98 458
99 459 // Capture output
100 460 ob_start();
101 461 try {
@@ -120,10 +480,23 @@
120 480 }
121 481
122 482 public function outputPdfDownload(string $pdfBinary, string $filename, bool $inline = false): void
123 483 {
484 + // Strip CR/LF (header injection) and any quotes/backslashes from the ASCII fallback name.
485 + // Keep an RFC 5987 UTF-8 form so non-ASCII filenames still display correctly in modern browsers.
486 + $cleanFilename = preg_replace('/[\r\n"\\\\]/', '', $filename) ?? '';
487 + if ($cleanFilename === '') {
488 + $cleanFilename = 'document.pdf';
489 + }
490 + $asciiFilename = preg_replace('/[^\x20-\x7E]/', '_', $cleanFilename) ?? 'document.pdf';
491 + $encodedFilename = rawurlencode($cleanFilename);
492 +
124 493 header('Content-Type: application/pdf');
125 - header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . $filename . '"');
494 + header(
495 + 'Content-Disposition: ' . ($inline ? 'inline' : 'attachment')
496 + . '; filename="' . $asciiFilename . '"'
497 + . "; filename*=UTF-8''" . $encodedFilename
498 + );
126 499 header('Cache-Control: no-cache, no-store, must-revalidate');
127 500 header('Pragma: no-cache');
128 501 header('Expires: 0');
129 502