Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
FrameSource
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
4 / 4
16
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markupFor
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 pathOf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hasMarkupExtension
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf;
6
7use Phpdftk\Filesystem\ResourceLoader;
8use Phpdftk\Html\Dom\Element;
9
10/**
11 * Resolves the markup an `<iframe>` / `<frame>` embeds — HTML §4.8.5,
12 * "the iframe element".
13 *
14 * An iframe hosts a *child navigable*: a whole second document, laid out
15 * in the iframe's content box. Everything downstream of that (parsing,
16 * cascade, layout, paint) is the ordinary pipeline; the only thing that
17 * is iframe-specific is deciding WHICH bytes to feed it, which is what
18 * this class isolates so it can be reasoned about — and tested — on its
19 * own.
20 *
21 * Resolution order, per spec:
22 *
23 *   1. `srcdoc` — its literal attribute value is the document, and it
24 *      wins outright over `src` when both are present.
25 *   2. `src`, for the schemes a static renderer can serve:
26 *      `about:blank` (the empty document), `data:text/html,…`, and a
27 *      same-origin path resolved through the renderer's sandbox.
28 *
29 * Anything else — `http(s)://`, `javascript:`, `blob:` — yields null and
30 * the frame renders as an empty box. Network fetching is deliberately not
31 * attempted here: a renderer that silently reached out to the network
32 * while laying out a page would be an SSRF vector in exactly the shape
33 * {@see \Phpdftk\Filesystem\LocalFilesystem::assertLocalPath()} exists to
34 * prevent. `data:` payloads are decoded in memory and never touch the
35 * filesystem layer.
36 */
37final readonly class FrameSource
38{
39    public function __construct(private ?ResourceLoader $loader = null) {}
40
41    /**
42     * The HTML markup `$frame` embeds, or null when the frame has no
43     * renderable document (unsupported scheme, unreadable path, absent
44     * `src`). An empty string is a meaningful result — `about:blank` and
45     * `srcdoc=""` both embed a real, empty document.
46     */
47    public function markupFor(Element $frame): ?string
48    {
49        $tag = strtolower($frame->localName);
50        if ($tag !== 'iframe' && $tag !== 'frame') {
51            return null;
52        }
53        // `srcdoc` is an inline document, not a URL: it is used verbatim
54        // and takes precedence over `src` (HTML §4.8.5). The attribute
55        // being PRESENT is what counts — `srcdoc=""` embeds an empty
56        // document rather than falling through to `src`.
57        $srcdoc = $frame->getAttribute('srcdoc');
58        if ($srcdoc !== null) {
59            return $srcdoc;
60        }
61        $src = trim($frame->getAttribute('src') ?? '');
62        if ($src === '') {
63            return null;
64        }
65        if (strcasecmp($src, 'about:blank') === 0) {
66            return '';
67        }
68        if (!str_starts_with(strtolower($src), 'data:')) {
69            // A relative / root-relative path. Disk paths carry no
70            // transport MIME, so the extension is the only signal for
71            // whether the target is markup — and getting that wrong is
72            // not harmless: `<iframe src="green.png">` handed a PNG's
73            // bytes to the HTML parser renders a frame full of
74            // `\x89PNG IHDR…IDAT` mojibake, which is a worse answer than
75            // an empty frame. A non-markup resource is an *image / plugin
76            // document*, which this does not model yet.
77            $path = self::pathOf($src);
78            if ($path === null || !self::hasMarkupExtension($path)) {
79                return null;
80            }
81            // The loader confines the path to the configured sandbox and
82            // refuses stream wrappers, so a frame cannot read outside the
83            // document tree it came from.
84            return $this->loader?->load($path);
85        }
86        // `data:text/html,…`. The MIME allowlist matters: a frame whose
87        // `src` is `data:image/png;base64,…` embeds an image document we
88        // cannot lay out as markup, and feeding its bytes to the HTML
89        // parser would produce a page of mojibake rather than nothing.
90        // A bare `data:,…` has no MIME at all and defaults to text/plain
91        // per rfc2397, so it is not markup either.
92        return $this->loader?->load($src, ['text/html']);
93    }
94
95    /**
96     * The file-path part of `$src`, with query string and fragment
97     * removed, or null when nothing is left.
98     *
99     * Both have to go before the path reaches the filesystem: there is no
100     * server here to interpret `?v=2`, so `support/x.html?v=2` names the
101     * file `support/x.html` and leaving the query attached simply fails
102     * the read.
103     */
104    private static function pathOf(string $src): ?string
105    {
106        // `strcspn` rather than `strtok`, which SKIPS leading delimiters
107        // and would turn the same-document reference `#frag` into the
108        // path `frag`.
109        $path = substr($src, 0, strcspn($src, '?#'));
110        return $path === '' ? null : $path;
111    }
112
113    /**
114     * Does `$path` name a markup resource?
115     *
116     * An extensionless path is accepted — a server path like
117     * `/common/blank` names a document, not a file type we can rule out.
118     */
119    private static function hasMarkupExtension(string $path): bool
120    {
121        $dot = strrpos($path, '.');
122        $slash = strrpos($path, '/');
123        if ($dot === false || ($slash !== false && $dot < $slash)) {
124            return true;
125        }
126        return in_array(
127            strtolower(substr($path, $dot + 1)),
128            ['html', 'htm', 'xhtml', 'xht'],
129            true,
130        );
131    }
132}