-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHtml.php
More file actions
297 lines (262 loc) · 10.1 KB
/
Copy pathHtml.php
File metadata and controls
297 lines (262 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Encode - HTML output encoding
*
* @package Italix\Encode
* @license MPL-2.0
*/
declare(strict_types=1);
namespace Italix\Encode;
/**
* A string that is already encoded for an HTML document.
*
* The problem this solves is not that escaping is hard — htmlspecialchars() is
* one call. It is that escaping is *invisible when absent*: a template that
* forgot it renders correctly for every value anyone tries by hand, and wrongly
* for the one value an attacker sends. Nothing in the page, the tests or the
* code review distinguishes the two.
*
* So the encoding is given a type. Markup that did not come from an encoder can
* only enter through raw(), which makes the deliberate cases greppable:
*
* grep -rn 'H::raw' src/Views src/Themes
*
* is the complete list of places where trusted markup is emitted — a review of
* a few lines instead of every echo in the codebase.
*
* Encoders compose by nesting, one per layer of the document, innermost first.
* JSON inside an HTML attribute is two layers, and attribs() applies them in
* that order; a value reaching a JS string inside an onclick is three, and no
* single call can express that — nesting can.
*
* Usage is by class alias, deliberately: there are no global helper functions,
* because a function declared in a template is a global function, and PHP
* fatals on the second include of the same partial.
*
* use Italix\Encode\Html as H;
*
* <h1><?= H::e($title) ?></h1>
* <div <?= H::attribs(['data-geo' => $geo, 'disabled' => $locked]) ?>>
* <a href="<?= H::url("/{$lang}/admin/index.html") ?>">
* <span><?= H::raw($icon_svg) ?></span>
* <script>const t = <?= H::j($labels) ?>;</script>
*
* This is an encoder, not a sanitizer. The two are different jobs: encoding is
* deterministic and lossless and depends on the output context; sanitizing is
* lossy, depends on a security policy, and works by whitelist. raw() does not
* look at what it is given. Untrusted rich content — uploaded SVG, rendered
* Markdown, pasted HTML — must pass a sanitizer before it reaches raw().
*/
final class Html
{
/**
* Attribute names we are willing to emit.
*
* Attribute names are not escapable — there is no encoding of `x onload=y`
* that keeps it one attribute. A name that does not match is refused.
*/
private const ATTRIBUTE_NAME = '/^[A-Za-z_:][A-Za-z0-9_:.\-]*$/';
/** Schemes allowed in href/src. Anything else is replaced, not escaped. */
private const SAFE_SCHEMES = ['http', 'https', 'mailto', 'tel', 'ftp', 'ftps'];
/**
* JSON flags for the JavaScript context.
*
* The HEX_* flags neutralise < > & ' " at the JSON layer, so the result is
* inert inside <script> regardless of what the HTML parser does with it.
* Unicode is deliberately left escaped: JSON_UNESCAPED_UNICODE would emit
* U+2028 and U+2029 literally, and those are line terminators in JavaScript
* — they break out of a string literal in engines predating ES2019.
* Slashes stay escaped for the same reason, so `</script>` cannot appear.
*/
private const JSON_FLAGS = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
private string $markup;
private function __construct(string $markup)
{
$this->markup = $markup;
}
// -------------------------------------------------------------------------
// Encoders
// -------------------------------------------------------------------------
/**
* Text and attribute values.
*
* One method for both because in HTML5, with attributes always quoted,
* they are the same operation: ENT_QUOTES covers ' and " as well as & < >.
* Splitting them would be a distinction the output does not have.
*
* Already-encoded values pass through unchanged, so double encoding — the
* &quot; that shows up on the page — cannot happen by composition.
*
* @param mixed $value
*/
public static function e($value): self
{
if ($value instanceof self) {
return $value;
}
return new self(htmlspecialchars(
self::stringify($value, 'e'),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
));
}
/**
* Trusted markup, emitted as-is.
*
* This is the audit surface. Every call is a claim that the argument is
* markup you produced, not data someone sent you.
*
* @param mixed $html
*/
public static function raw($html): self
{
if ($html instanceof self) {
return $html;
}
return new self(self::stringify($html, 'raw'));
}
/**
* A JavaScript literal, for use inside <script>.
*
* Takes the data, not a pre-encoded string: json_encode() on the caller's
* side is safe only by accident of the default flags, and the accident ends
* the day someone adds JSON_UNESCAPED_SLASHES to make URLs readable.
*
* @param mixed $value
*/
public static function j($value): self
{
if ($value instanceof self) {
throw new \InvalidArgumentException(
'Html::j() was given already-encoded markup. A JavaScript literal is not '
. 'an HTML context — pass the underlying data instead.'
);
}
$json = json_encode($value, self::JSON_FLAGS);
if ($json === false) {
throw new \InvalidArgumentException(
'Html::j() could not encode the value: ' . json_last_error_msg()
);
}
return new self($json);
}
/**
* A list of attributes.
*
* Values are encoded by type, which is where the two-layer cases stop
* needing a decision from the caller:
*
* true → the bare name (disabled, required, checked)
* false, null → the attribute is omitted entirely
* array, object → JSON, then attribute encoding
* Html → used as-is; it is already encoded
* anything else → attribute encoding
*
* No leading or trailing space is added; place it where you use it.
*
* @param array<string,mixed> $attributes
*/
public static function attribs(array $attributes): self
{
$pairs = [];
foreach ($attributes as $name => $value) {
if (!is_string($name) || !preg_match(self::ATTRIBUTE_NAME, $name)) {
throw new \InvalidArgumentException(sprintf(
'Html::attribs(): "%s" is not a valid attribute name.',
is_scalar($name) ? (string) $name : gettype($name)
));
}
if ($value === null || $value === false) {
continue;
}
if ($value === true) {
$pairs[] = $name;
continue;
}
// (string) first: e() would pass an Html through untouched, and the
// JSON layer still has to be encoded for the attribute layer.
$encoded = self::needs_json($value)
? self::e((string) self::j($value))
: self::e($value);
$pairs[] = $name . '="' . $encoded->markup . '"';
}
return new self(implode(' ', $pairs));
}
/**
* A URL for href/src, with an optional query string.
*
* Encoding alone does not make a URL safe: javascript:alert(1) contains
* nothing htmlspecialchars() would touch. So the scheme is checked, and a
* rejected one is replaced by about:blank rather than escaped — the link
* stays in the page, visibly inert, instead of the page failing to render.
*
* The path is not percent-encoded: it is a path, and its slashes are
* structure. Pass user-supplied values through $query, which encodes them.
*
* @param array<string,mixed> $query
*/
public static function url(string $url, array $query = []): self
{
if ($query !== []) {
$url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($query);
}
// Browsers strip control characters before resolving the scheme, so
// "java\tscript:alert(1)" is javascript:. Strip them before looking.
$probe = (string) preg_replace('/[\x00-\x20\x7F]/', '', $url);
if (preg_match('/^([A-Za-z][A-Za-z0-9+.\-]*):/', $probe, $m)
&& !in_array(strtolower($m[1]), self::SAFE_SCHEMES, true)) {
return new self('about:blank');
}
return self::e($url);
}
// -------------------------------------------------------------------------
// Output
// -------------------------------------------------------------------------
public function __toString(): string
{
return $this->markup;
}
// -------------------------------------------------------------------------
// Private
// -------------------------------------------------------------------------
/**
* @param mixed $value
*/
private static function needs_json($value): bool
{
if (is_array($value)) {
return true;
}
return is_object($value)
&& !$value instanceof self
&& !method_exists($value, '__toString');
}
/**
* @param mixed $value
*/
private static function stringify($value, string $method): string
{
if (is_array($value)) {
throw new \InvalidArgumentException(sprintf(
'Html::%s() was given an array. Encode it with Html::j(), or pick an element.',
$method
));
}
if (is_object($value) && !method_exists($value, '__toString')) {
throw new \InvalidArgumentException(sprintf(
'Html::%s() was given an instance of %s, which has no string form.',
$method,
get_class($value)
));
}
if (is_bool($value)) {
return $value ? '1' : '';
}
return (string) $value;
}
}