Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
29 / 29 |
|
100.00% |
4 / 4 |
CRAP | |
100.00% |
1 / 1 |
| FrameSource | |
100.00% |
29 / 29 |
|
100.00% |
4 / 4 |
16 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| markupFor | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
9 | |||
| pathOf | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| hasMarkupExtension | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf; |
| 6 | |
| 7 | use Phpdftk\Filesystem\ResourceLoader; |
| 8 | use 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 | */ |
| 37 | final 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 | } |