fuzzyworld

fuzzyworld-tools / files / server / comments / post.php

post.php

14.3 KB · history · blame

1<?php
2declare(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
38const PATH_RE = '#^/([A-Za-z0-9_~][A-Za-z0-9._%~+-]*/){1,8}\z#'; // same as comments.py PATH_RE
39const MIN_MS = 3000;
40const CONFIRM_MIN_S = 3;
41const CONFIRM_MAX_S = 3600;
42const RATE_MAX = 5;
43const RATE_WINDOW = 3600;
44const PENDING_CAP = 200;
45const FORM_CAP = 100;
46const MAX_NAME = 60;
47const MAX_TEXT = 4000;
48
49ini_set('display_errors', '0');
50
51function 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
59function 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. */
70function 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
76function h(string $s): string
77{
78 return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
79}
80
81function 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
91function expand(string $p): string
92{
93 return ($p === '~' || str_starts_with($p, '~/')) ? home_dir() . substr($p, 1) : $p;
94}
95
96function 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). */
102function ulen(string $s): int
103{
104 return (int)preg_match_all('/./su', $s);
105}
106
107function 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 */
129function 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 '']. */
138function 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. */
159function 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
172function 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
188function 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
193function 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
347try {
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}