PluginProbe
CryptX / trunk
CryptX vtrunk
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / Exposure.php

Exposure.php in CryptX trunk, at classes/Exposure.php

173 lines 7.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX;
4
5 /**
6 * How exposed an address is in a piece of finished markup.
7 *
8 * Three answers, not two. Several display options put the address into the
9 * markup as HTML entities -- in an alt text, for instance, where a screen
10 * reader needs it. A search of the raw markup finds nothing there, and saying
11 * "no readable address" would be true of the bytes and false of the situation:
12 * any bot that decodes entities, and most do, reads it straight off.
13 *
14 * This lives on its own because two very different places need the same
15 * answer: the live preview on the settings screen, and the Site Health check.
16 * A second implementation would drift, and the two would disagree about the
17 * same page.
18 *
19 * @package CryptX
20 * @since 4.2.0
21 */
22 final class Exposure
23 {
24 /**
25 * The address both self-checks render.
26 *
27 * RFC 2606 reserves example.com, so this can never be a real person's
28 * address. It lives next to the judgement for the same reason the
29 * judgement is shared at all: the settings preview and the Site Health
30 * check have to measure the same thing the same way, and two copies of the
31 * sample would eventually drift apart just as two copies of Exposure::of()
32 * would.
33 */
34 public const SAMPLE_ADDRESS = 'info@example.com';
35
36 /** Nothing resembling an address is left. */
37 public const NONE = 'none';
38
39 /** Readable only after decoding HTML entities. */
40 public const ENCODED = 'encoded';
41
42 /** Plainly readable. */
43 public const PLAIN = 'plain';
44
45 /**
46 * What the plugin itself considers an address, without delimiters.
47 *
48 * Kept apart from the pattern below because it is used twice: once
49 * unanchored, to find addresses in a piece of markup, and once anchored, to
50 * ask whether a whole string is one. Building the anchored form by trimming
51 * the delimiters off the finished pattern worked, but only as long as
52 * nobody added a modifier -- "/.../i" would have become "/^... /i$/", which
53 * preg_match() rejects outright. Every address would then have been judged
54 * "not an address": no block would render anywhere, fail-closed and very
55 * hard to trace back to a lone "i".
56 */
57 private const ADDRESS_EXPRESSION =
58 '[_a-zA-Z0-9-+]+(\.[_a-zA-Z0-9-+]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*(\.[a-zA-Z]{2,})';
59
60 /**
61 * The pattern the plugin itself uses to find addresses, turned against its
62 * own output.
63 */
64 private const ADDRESS_PATTERN = '/' . self::ADDRESS_EXPRESSION . '/';
65
66 /**
67 * Everything invisible that a paste can drag along.
68 *
69 * These are exactly the 29 code points JavaScript's \s matches, plus the
70 * zero-width group (U+200B..U+200D, U+2060) that it does not. Written out
71 * rather than left to PHP's \s, which covers six of them and would put the
72 * editor and the server back into disagreement over a character nobody can
73 * see -- see cleanAddress().
74 */
75 private const INVISIBLE_PATTERN =
76 '/[\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200D}'
77 . '\x{2028}\x{2029}\x{202F}\x{205F}\x{2060}\x{3000}\x{FEFF}]+/u';
78
79 /**
80 * Judges a piece of markup.
81 *
82 * @param string $markup The processed markup.
83 *
84 * @return string One of the three constants above.
85 */
86 public static function of(string $markup): string
87 {
88 if (preg_match(self::ADDRESS_PATTERN, $markup)) {
89 return self::PLAIN;
90 }
91
92 $decoded = html_entity_decode($markup, ENT_QUOTES | ENT_HTML5, 'UTF-8');
93
94 if (preg_match(self::ADDRESS_PATTERN, $decoded)) {
95 return self::ENCODED;
96 }
97
98 return self::NONE;
99 }
100
101 /**
102 * Whether CryptX would recognise this as an address at all.
103 *
104 * Not the same question as is_email(), and the difference matters wherever
105 * something promises protection. is_email() accepts what RFC 5321 allows in
106 * a local part, which includes quotes and slashes; the pattern above is
107 * what the plugin actually looks for. An address that passes the one and
108 * not the other -- "x'/y'@example.com" -- travels through every stage
109 * untouched and arrives in the page in plain text, under a heading that
110 * says it is protected.
111 *
112 * So the gate is this, not is_email(): if it renders, it is protected, and
113 * if it will not be protected, it does not render.
114 *
115 * @param string $value The address as it was written.
116 *
117 * @return bool True when the three stages will find it.
118 */
119 public static function isAddress(string $value): bool
120 {
121 // The "D" matters. Without it PCRE lets "$" match before a trailing
122 // newline, so "info@example.com\n" would be judged an address -- and
123 // JavaScript's "$" does not do that, so the two sides would disagree
124 // about a value with a line break at the end. It went unnoticed while
125 // this method trimmed for itself; taking the trim out (the caller
126 // normalises, and judging one string while using another is how a
127 // validator ends up guarding nothing) is what brought it to the
128 // surface.
129 return (bool) preg_match('/^' . self::ADDRESS_EXPRESSION . '$/D', $value);
130 }
131
132 /**
133 * Removes what a paste dragged in with the address.
134 *
135 * An address never contains whitespace, so taking all of it out -- not just
136 * at the edges -- is safe, and it is the only way the editor and the server
137 * can agree. Copy an address out of Word or a PDF and it usually arrives
138 * with a non-breaking space attached. PHP's trim() leaves that one alone,
139 * so an address cleaned only by the browser was accepted there and refused
140 * here, and the block was simply absent from the published page over a
141 * character nobody can see.
142 *
143 * Doing it on both sides rather than warning about it: at the edges of a
144 * value there is exactly one address the author meant, and it is the one
145 * without the invisible character. That is what separates this from the
146 * exemption list in Admin\SettingsSchema, which rejects rather than
147 * repairs -- there, repairing would decide WHICH address loses its
148 * protection, and a silently changed entry is not noticed.
149 *
150 * Inside a value the argument is weaker and worth knowing: "a@exam b.com"
151 * is not an address at all, and cleaning makes one out of two visible
152 * pieces. It stays harmless because joining can only concatenate, never
153 * substitute, and the joined result is what the page then shows. Still --
154 * this belongs on values meant as ONE address, never on a list or on
155 * several lines of text.
156 *
157 * The editor does the same, in cleanAddress() in src/block/index.js. The
158 * two lists have to stay equal; the test case "the editor and the server
159 * agree on what an address is" is what notices if they stop being.
160 *
161 * @param string $value The address as it arrived.
162 *
163 * @return string The address without invisible characters.
164 */
165 public static function cleanAddress(string $value): string
166 {
167 $cleaned = preg_replace(self::INVISIBLE_PATTERN, '', $value);
168
169 // A PCRE failure must not turn into a silently accepted address.
170 return $cleaned ?? $value;
171 }
172 }
173