| 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 |
|