fuzzyworld-tools / files / server / comments / post.php
post.php
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | /* |
| 5 | * Comment form endpoint (T2 in the README tiers: server-side code that takes input from the |
| 6 | * public internet). OFF BY DEFAULT: nothing installs or enables this file; it lives in the repo |
| 7 | * until the comment form is switched on and this script is deployed to its own subdomain |
| 8 | * (e.g. comments.fuzzyworld.net, PHP 8.2, running as the account user). |
| 9 | * |
| 10 | * What it does: validates one POST from the comment form (fields site, path, elapsed, website, |
| 11 | * name, text), and writes ONE file, <state>/pending/<id>.json, in exactly the format comments.py |
| 12 | * writes for mail (source "form", sender_hash null). Nothing is ever published from here: a human |
| 13 | * runs `fw comments list / approve`, and `approve --trust` refuses form comments (no sender_hash). |
| 14 | * |
| 15 | * Threat model: every field is hostile. The only things the client controls that matter are |
| 16 | * strings, which are (1) checked against the same path rules as comments.py and sites.json, |
| 17 | * (2) cleaned of control/format characters, (3) length-capped, (4) stored as JSON data and |
| 18 | * never executed, included, or used as a file name; the only file read is |
| 19 | * <site root><validated path>index.html, and the redirect target is built from sites.json plus the |
| 20 | * validated path, never from the request. Output is fixed HTML; request values appear only through |
| 21 | * htmlspecialchars (the "are you human?" confirm page). Abuse is limited by a honeypot, a minimum |
| 22 | * time, 5 accepted comments per hour per hashed IP (IPv6: per /64), a cap of 100 pending form |
| 23 | * comments, and the global cap of 200 pending files. Be honest about the first two: `elapsed` is |
| 24 | * supplied by the client, so the minimum-time check and the confirm step only stop naive bots; a |
| 25 | * targeted script can sign its own confirm round trip. The real limits are the rate limit, the |
| 26 | * caps, and human moderation. Cross-site posts are refused by the Origin check (sites.json |
| 27 | * origins plus the optional "self_origin", the origin this script is served from). No cookies, |
| 28 | * no sessions, no error details to the client, errors go to error_log. |
| 29 | * |
| 30 | * Config (environment, for tests and odd layouts): |
| 31 | * FW_COMMENTS_DIR state directory, default ~/fw/comments (the same one comments.py uses) |
| 32 | * FW_COMMENTS_SITES sites.json, default: the one next to this file |
| 33 | * FW_COMMENTS_SELF_ORIGIN overrides "self_origin" in sites.json (e.g. https://comments.fuzzyworld.net) |
| 34 | * Needs only core PHP (no mbstring, no ctype). |
| 35 | * <state>/secret must already exist (comments.py creates it); without it this script fails with 500. |
| 36 | */ |
| 37 | |
| 38 | const PATH_RE = '#^/([A-Za-z0-9_~][A-Za-z0-9._%~+-]*/){1,8}\z#'; // same as comments.py PATH_RE |
| 39 | const MIN_MS = 3000; |
| 40 | const CONFIRM_MIN_S = 3; |
| 41 | const CONFIRM_MAX_S = 3600; |
| 42 | const RATE_MAX = 5; |
| 43 | const RATE_WINDOW = 3600; |
| 44 | const PENDING_CAP = 200; |
| 45 | const FORM_CAP = 100; |
| 46 | const MAX_NAME = 60; |
| 47 | const MAX_TEXT = 4000; |
| 48 | |
| 49 | ini_set('display_errors', '0'); |
| 50 | |
| 51 | function send_headers(): void |
| 52 | { |
| 53 | header('Content-Type: text/html; charset=utf-8'); |
| 54 | header('X-Content-Type-Options: nosniff'); |
| 55 | header('Cache-Control: no-store'); |
| 56 | header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'"); |
| 57 | } |
| 58 | |
| 59 | function page(int $code, string $body): never |
| 60 | { |
| 61 | http_response_code($code); |
| 62 | send_headers(); |
| 63 | echo '<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">' |
| 64 | . '<title>Comment</title><style>body{font:16px/1.5 sans-serif;max-width:32em;margin:2em auto;padding:0 1em}' |
| 65 | . 'pre{white-space:pre-wrap}</style><body>' . $body . '</body></html>'; |
| 66 | exit; |
| 67 | } |
| 68 | |
| 69 | /** Refuse with a fixed message; $why (a fixed string, never request data) goes to the log only. */ |
| 70 | function refuse(int $code, string $why): never |
| 71 | { |
| 72 | error_log("comments post.php: $code $why"); |
| 73 | page($code, '<p>Sorry, this comment could not be accepted.</p>'); |
| 74 | } |
| 75 | |
| 76 | function h(string $s): string |
| 77 | { |
| 78 | return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); |
| 79 | } |
| 80 | |
| 81 | function home_dir(): string |
| 82 | { |
| 83 | $home = getenv('HOME'); |
| 84 | if (($home === false || $home === '') && function_exists('posix_getpwuid')) { |
| 85 | $pw = posix_getpwuid(posix_getuid()); |
| 86 | $home = $pw['dir'] ?? ''; |
| 87 | } |
| 88 | return rtrim((string)$home, '/'); |
| 89 | } |
| 90 | |
| 91 | function expand(string $p): string |
| 92 | { |
| 93 | return ($p === '~' || str_starts_with($p, '~/')) ? home_dir() . substr($p, 1) : $p; |
| 94 | } |
| 95 | |
| 96 | function is_utf8(string $s): bool |
| 97 | { |
| 98 | return preg_match('//u', $s) === 1; |
| 99 | } |
| 100 | |
| 101 | /** Length in characters (the string must be valid UTF-8). */ |
| 102 | function ulen(string $s): int |
| 103 | { |
| 104 | return (int)preg_match_all('/./su', $s); |
| 105 | } |
| 106 | |
| 107 | function valid_path(string $path): bool |
| 108 | { |
| 109 | if (strlen($path) > 300 || !preg_match(PATH_RE, $path) || str_contains($path, '..')) { |
| 110 | return false; |
| 111 | } |
| 112 | foreach (explode('/', trim($path, '/')) as $seg) { |
| 113 | $decoded = rawurldecode($seg); // comments.py: unquote(), where bad UTF-8 becomes U+FFFD |
| 114 | if (!is_utf8($decoded) || $decoded === '.' || str_contains($decoded, '..') || str_contains($decoded, "\u{FFFD}") |
| 115 | || str_contains($decoded, '/') || str_contains($decoded, '\\') |
| 116 | || preg_match('/[\p{Cc}\p{Cf}]/u', $decoded)) { |
| 117 | return false; |
| 118 | } |
| 119 | } |
| 120 | return true; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * The canonical spelling of a validated post path, identical to comments.py canonical_path(): |
| 125 | * every segment decoded, then re-escaped like urllib.parse.quote(..., safe="~+"): letters, |
| 126 | * digits and _.-~+ stay, every other byte is %XX in upper case. So /sl%75g/ is /slug/ and |
| 127 | * %c3%a9 is %C3%A9. Keep the two in step. |
| 128 | */ |
| 129 | function canonical_path(string $path): string |
| 130 | { |
| 131 | $canon = '/' . implode('/', array_map( |
| 132 | fn($seg) => str_replace('%2B', '+', rawurlencode(rawurldecode($seg))), |
| 133 | explode('/', trim($path, '/')))) . '/'; |
| 134 | return valid_path($canon) ? $canon : ''; |
| 135 | } |
| 136 | |
| 137 | /** [sites, self origin or '']. */ |
| 138 | function load_sites(string $file): array |
| 139 | { |
| 140 | $data = json_decode((string)@file_get_contents($file), true); |
| 141 | if (!is_array($data)) { |
| 142 | refuse(500, 'cannot read sites.json'); |
| 143 | } |
| 144 | $sites = []; |
| 145 | foreach ($data as $name => $cfg) { |
| 146 | if (is_array($cfg) && is_string($cfg['root'] ?? null) && is_string($cfg['origin'] ?? null) |
| 147 | && preg_match('~^https?://[A-Za-z0-9.:-]+\z~', rtrim($cfg['origin'], '/'))) { |
| 148 | $sites[(string)$name] = ['root' => rtrim(expand($cfg['root']), '/'), 'origin' => rtrim($cfg['origin'], '/')]; |
| 149 | } |
| 150 | } |
| 151 | $self = rtrim((string)(getenv('FW_COMMENTS_SELF_ORIGIN') ?: ($data['self_origin'] ?? '')), '/'); |
| 152 | if ($self !== '' && !preg_match('~^https?://[A-Za-z0-9.:-]+\z~', $self)) { |
| 153 | $self = ''; |
| 154 | } |
| 155 | return [$sites, $self]; |
| 156 | } |
| 157 | |
| 158 | /** Control and format characters (bidi, zero-width, ...) and U+2028/2029 removed; "\n" kept in text. */ |
| 159 | function clean(string $s, bool $keep_newlines): string |
| 160 | { |
| 161 | $s = str_replace(["\r\n", "\r"], "\n", $s); |
| 162 | if (!$keep_newlines) { |
| 163 | $s = str_replace("\n", ' ', $s); |
| 164 | } |
| 165 | $out = preg_replace('/(?!\n)[\p{Cc}\p{Cf}\x{2028}\x{2029}]/u', '', $s); |
| 166 | if ($out === null) { |
| 167 | refuse(400, 'cleaning failed'); |
| 168 | } |
| 169 | return trim($out); |
| 170 | } |
| 171 | |
| 172 | function write_atomic(string $path, string $data): void |
| 173 | { |
| 174 | $dir = dirname($path); |
| 175 | if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { |
| 176 | throw new RuntimeException('cannot create directory'); |
| 177 | } |
| 178 | $tmp = tempnam($dir, '.tmp-'); |
| 179 | if ($tmp === false) { |
| 180 | throw new RuntimeException('cannot create temp file'); |
| 181 | } |
| 182 | if (file_put_contents($tmp, $data) !== strlen($data) || !chmod($tmp, 0600) || !rename($tmp, $path)) { |
| 183 | @unlink($tmp); |
| 184 | throw new RuntimeException('cannot write file'); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | function sign(string $secret, string $ts, string $site, string $path, string $name, string $text): string |
| 189 | { |
| 190 | return hash_hmac('sha256', $ts . '|' . $site . '|' . $path . '|' . hash('sha256', $name) . '|' . hash('sha256', $text), $secret); |
| 191 | } |
| 192 | |
| 193 | function main(): void |
| 194 | { |
| 195 | if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { |
| 196 | header('Allow: POST'); |
| 197 | refuse(405, 'method not allowed'); |
| 198 | } |
| 199 | |
| 200 | $state = rtrim(expand(getenv('FW_COMMENTS_DIR') ?: '~/fw/comments'), '/'); |
| 201 | [$sites, $selfOrigin] = load_sites(getenv('FW_COMMENTS_SITES') ?: __DIR__ . '/sites.json'); |
| 202 | |
| 203 | if (isset($_SERVER['HTTP_ORIGIN'])) { |
| 204 | $allowed = array_column($sites, 'origin'); |
| 205 | if ($selfOrigin !== '') { |
| 206 | $allowed[] = $selfOrigin; // the confirm page posts back to this origin |
| 207 | } |
| 208 | if (!in_array($_SERVER['HTTP_ORIGIN'], $allowed, true)) { |
| 209 | refuse(403, 'bad origin'); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | $secret = is_file("$state/secret") ? trim((string)file_get_contents("$state/secret")) : ''; |
| 214 | if ($secret === '') { |
| 215 | refuse(500, "missing $state/secret (run comments.py once)"); |
| 216 | } |
| 217 | |
| 218 | $in = []; |
| 219 | foreach (['site', 'path', 'elapsed', 'website', 'name', 'text', 'ts', 'sig'] as $k) { |
| 220 | $v = $_POST[$k] ?? ''; |
| 221 | if (!is_string($v) || strlen($v) > 16000) { |
| 222 | refuse(400, 'bad field'); |
| 223 | } |
| 224 | $in[$k] = $v; |
| 225 | } |
| 226 | |
| 227 | $site = $in['site']; |
| 228 | $path = $in['path']; |
| 229 | if (!isset($sites[$site]) || !valid_path($path) || !is_dir($sites[$site]['root']) |
| 230 | || !is_file($sites[$site]['root'] . '/' . rawurldecode(trim($path, '/')) . '/index.html')) { // as comments.py fs_path |
| 231 | refuse(400, 'unknown site or post'); |
| 232 | } |
| 233 | $path = canonical_path($path); // stored and redirected in the canonical spelling |
| 234 | if ($path === '') { |
| 235 | refuse(400, 'path not canonical'); |
| 236 | } |
| 237 | $thanks = $sites[$site]['origin'] . $path . '#comment-pending'; |
| 238 | |
| 239 | if ($in['website'] !== '') { // honeypot: looks like success, stores nothing |
| 240 | http_response_code(303); |
| 241 | header('Location: ' . $thanks); |
| 242 | send_headers(); |
| 243 | return; |
| 244 | } |
| 245 | |
| 246 | if (!is_utf8($in['name']) || !is_utf8($in['text'])) { |
| 247 | refuse(400, 'invalid utf-8'); |
| 248 | } |
| 249 | $name = clean($in['name'], false); |
| 250 | $text = clean($in['text'], true); |
| 251 | if ($name === '' || ulen($name) > MAX_NAME || $text === '' || ulen($text) > MAX_TEXT) { |
| 252 | refuse(400, 'bad name or text length'); |
| 253 | } |
| 254 | if (str_contains($name, '@')) { |
| 255 | $name = 'A reader'; |
| 256 | } |
| 257 | |
| 258 | $now = time(); |
| 259 | if ($in['sig'] !== '') { // second step: the visitor confirmed |
| 260 | $ok = preg_match('/^\d{1,12}\z/', $in['ts']) === 1 |
| 261 | && hash_equals(sign($secret, $in['ts'], $site, $path, $name, $text), $in['sig']) |
| 262 | && ($now - (int)$in['ts']) >= CONFIRM_MIN_S && ($now - (int)$in['ts']) <= CONFIRM_MAX_S; |
| 263 | if (!$ok) { |
| 264 | refuse(400, 'bad or expired confirmation'); |
| 265 | } |
| 266 | } elseif (preg_match('/^\d{1,9}\z/', $in['elapsed']) !== 1 || (int)$in['elapsed'] < MIN_MS) { |
| 267 | $ts = (string)$now; |
| 268 | page(200, '<p>That was quick. Please confirm that you are a person and want to post this comment:</p>' |
| 269 | . '<p><strong>' . h($name) . '</strong></p><pre>' . h($text) . '</pre>' |
| 270 | . '<form method="post" action="">' |
| 271 | . '<input type="hidden" name="site" value="' . h($site) . '">' |
| 272 | . '<input type="hidden" name="path" value="' . h($path) . '">' |
| 273 | . '<input type="hidden" name="name" value="' . h($name) . '">' |
| 274 | . '<input type="hidden" name="text" value="' . h($text) . '">' |
| 275 | . '<input type="hidden" name="ts" value="' . h($ts) . '">' |
| 276 | . '<input type="hidden" name="sig" value="' . h(sign($secret, $ts, $site, $path, $name, $text)) . '">' |
| 277 | . '<button type="submit">Post comment</button></form>'); |
| 278 | } |
| 279 | |
| 280 | // Accept. One lock (the one comments.py uses) covers rate limit, cap and write. |
| 281 | if (!is_dir($state) && !@mkdir($state, 0700, true) && !is_dir($state)) { |
| 282 | throw new RuntimeException('cannot create state directory'); |
| 283 | } |
| 284 | $lock = fopen("$state/lock", 'c'); |
| 285 | if ($lock === false || !flock($lock, LOCK_EX)) { |
| 286 | throw new RuntimeException('cannot lock'); |
| 287 | } |
| 288 | |
| 289 | $addr = (string)($_SERVER['REMOTE_ADDR'] ?? ''); |
| 290 | $packed = @inet_pton($addr); |
| 291 | if ($packed !== false && strlen($packed) === 16) { // IPv6: one visitor owns a whole /64 |
| 292 | $addr = bin2hex(substr($packed, 0, 8)); |
| 293 | } |
| 294 | $iphash = hash_hmac('sha256', $addr, $secret); |
| 295 | $rfile = "$state/rate/$iphash"; |
| 296 | $recent = []; |
| 297 | foreach (is_file($rfile) ? (file($rfile, FILE_IGNORE_NEW_LINES) ?: []) : [] as $line) { |
| 298 | if (preg_match('/^\d{1,12}\z/', $line) === 1 && (int)$line > $now - RATE_WINDOW) { |
| 299 | $recent[] = (int)$line; |
| 300 | } |
| 301 | } |
| 302 | if (count($recent) >= RATE_MAX) { |
| 303 | refuse(429, 'rate limit'); |
| 304 | } |
| 305 | $files = glob("$state/pending/*.json") ?: []; |
| 306 | if (count($files) >= PENDING_CAP) { |
| 307 | refuse(503, 'pending queue full'); |
| 308 | } |
| 309 | $forms = 0; |
| 310 | foreach ($files as $f) { |
| 311 | $c = json_decode((string)@file_get_contents($f), true); |
| 312 | if (is_array($c) && ($c['source'] ?? '') === 'form') { |
| 313 | $forms++; |
| 314 | } |
| 315 | } |
| 316 | if ($forms >= FORM_CAP) { |
| 317 | refuse(503, 'pending form queue full'); |
| 318 | } |
| 319 | |
| 320 | $id = gmdate('Ymd\THis\Z', $now) . '-' . bin2hex(random_bytes(3)); |
| 321 | $record = [ |
| 322 | 'id' => $id, 'site' => $site, 'path' => $path, 'author' => $name, 'text' => $text, |
| 323 | 'date' => gmdate('Y-m-d\TH:i:s\Z', $now), 'source' => 'form', 'parent' => null, 'sender_hash' => null, |
| 324 | ]; |
| 325 | ksort($record); |
| 326 | $json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); |
| 327 | $json = preg_replace_callback('/^ +/m', fn($m) => str_repeat(' ', intdiv(strlen($m[0]), 2)), $json); // 2-space indent like comments.py |
| 328 | write_atomic("$state/pending/$id.json", $json . "\n"); |
| 329 | |
| 330 | try { // the comment is stored; a failed rate record must not turn that into an error |
| 331 | $recent[] = $now; |
| 332 | write_atomic($rfile, implode("\n", $recent) . "\n"); |
| 333 | foreach (glob("$state/rate/*") ?: [] as $f) { // forget other addresses after the window |
| 334 | if (is_file($f) && filemtime($f) < $now - RATE_WINDOW) { |
| 335 | @unlink($f); |
| 336 | } |
| 337 | } |
| 338 | } catch (Throwable $e) { |
| 339 | error_log('comments post.php: rate record not written: ' . $e->getMessage()); |
| 340 | } |
| 341 | |
| 342 | http_response_code(303); |
| 343 | header('Location: ' . $thanks); |
| 344 | send_headers(); |
| 345 | } |
| 346 | |
| 347 | try { |
| 348 | main(); |
| 349 | } catch (Throwable $e) { |
| 350 | error_log('comments post.php: ' . get_class($e) . ': ' . $e->getMessage()); |
| 351 | if (!headers_sent()) { |
| 352 | http_response_code(500); |
| 353 | send_headers(); |
| 354 | echo '<!doctype html><meta charset="utf-8"><p>Sorry, something went wrong.</p>'; |
| 355 | } |
| 356 | } |