From cb606fa839ff96fa61329a6b77311a500688a36e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 06:00:12 +0000 Subject: [PATCH 1/2] Add layer-7 DOS mitigations to router.php Recent abuse posted directly to lib/router.php in rapid succession. The endpoint was wide open: no method check, no size cap, no proof the caller ever loaded the page. This adds stateless mitigations bound to data the request is already touching, with one tiny shared secret in data/.secret. - POST-only enforcement on save and savecomment; $_REQUEST replaced with explicit $_POST / $_GET so query-string smuggling cannot reach writes. - Hard size limits on content (512 KB), comment (4 KB), user (64 bytes). - HMAC-signed action tokens with 30 min TTL: - save: token bound to scope 'save', emitted as in index.php (index.html renamed to index.php), read by script.js at startup. - savecomment: token bound to the paste's uid, returned alongside comments by do=loadcomments and verified against the posted uid so a comment token cannot be replayed against a different paste. - Per-paste comment cooldown: reject savecomment when the .comments file's mtime is less than COMMENT_DELAY (3s) ago. Uses filesystem state that already exists; no extra bookkeeping. - Shared token helpers and tunables live in lib/lib.php so index.php can issue tokens without invoking the router dispatcher. https://claude.ai/code/session_016Kh8s3xrcng9Dgd9Uui6PY --- index.html => index.php | 5 +++- lib/lib.php | 53 +++++++++++++++++++++++++++++++++++++++++ lib/router.php | 46 ++++++++++++++++++++++++++++------- lib/script.js | 15 ++++++++---- 4 files changed, 105 insertions(+), 14 deletions(-) rename index.html => index.php (88%) create mode 100644 lib/lib.php diff --git a/index.html b/index.php similarity index 88% rename from index.html rename to index.php index 5ed3ceb..3b6e0da 100644 --- a/index.html +++ b/index.php @@ -1,6 +1,9 @@ - + + commie ☭ diff --git a/lib/lib.php b/lib/lib.php new file mode 100644 index 0000000..2de1ded --- /dev/null +++ b/lib/lib.php @@ -0,0 +1,53 @@ + TOKEN_TTL) return false; + return true; +} diff --git a/lib/router.php b/lib/router.php index 4612b4a..5be8d93 100644 --- a/lib/router.php +++ b/lib/router.php @@ -1,31 +1,59 @@ savePaste($_REQUEST['content']); + if ($method !== 'POST') { http_response_code(405); break; } + $content = isset($_POST['content']) ? $_POST['content'] : ''; + $token = isset($_POST['token']) ? $_POST['token'] : ''; + if (strlen($content) > MAX_PASTE_BYTES) { http_response_code(413); break; } + if (!commie_token_valid('save', $token)) { http_response_code(403); break; } + $ret = $paste->savePaste($content); break; + case 'load': - $ret = htmlspecialchars($paste->loadPaste($_REQUEST['uid'])); + $uid = commie_uid(isset($_GET['uid']) ? $_GET['uid'] : ''); + $ret = htmlspecialchars($paste->loadPaste($uid)); break; + case 'loadcomments': - $ret = $paste->loadComments($_REQUEST['uid']); + $uid = commie_uid(isset($_GET['uid']) ? $_GET['uid'] : ''); + $ret = [ + 'comments' => $paste->loadComments($uid), + 'token' => commie_token($uid), + ]; break; + case 'savecomment': - $ret = $paste->saveComment($_REQUEST['uid'], (int) $_REQUEST['line'], $_REQUEST['comment'], $_REQUEST['user']); + if ($method !== 'POST') { http_response_code(405); break; } + $uid = commie_uid(isset($_POST['uid']) ? $_POST['uid'] : ''); + $line = isset($_POST['line']) ? (int) $_POST['line'] : 0; + $comment = isset($_POST['comment']) ? $_POST['comment'] : ''; + $user = isset($_POST['user']) ? $_POST['user'] : ''; + $token = isset($_POST['token']) ? $_POST['token'] : ''; + if (strlen($comment) > MAX_COMMENT_BYTES) { http_response_code(413); break; } + if (strlen($user) > MAX_USER_BYTES) { http_response_code(413); break; } + if (!commie_token_valid($uid, $token)) { http_response_code(403); break; } + $commentsPath = $paste->fn($uid, 'comments'); + if (file_exists($commentsPath) && (time() - filemtime($commentsPath)) < COMMENT_DELAY) { + http_response_code(429); + break; + } + $ret = $paste->saveComment($uid, $line, $comment, $user); break; + default: $ret = false; } header('Content-Type: application/json'); echo json_encode($ret); - diff --git a/lib/script.js b/lib/script.js index dbcd936..f82b92c 100644 --- a/lib/script.js +++ b/lib/script.js @@ -1,4 +1,6 @@ var API = 'lib/router.php'; +var saveToken = $('meta[name="commie-token"]').attr('content') || ''; +var commentToken = ''; /** * Saves a new paste @@ -12,7 +14,8 @@ function save() { API, { do: 'save', - 'content': content + 'content': content, + token: saveToken }, function (data) { if (data === false) { @@ -59,10 +62,13 @@ function loadcomments(uid) { uid: uid }, function (data) { + var comments = (data && data.comments) || []; + commentToken = (data && data.token) || ''; + var $lines = $('#paste').find('li'); - for (var i = 0; i < data.length; i++) { - commentshow($($lines.get(data[i].line)), data[i]); + for (var i = 0; i < comments.length; i++) { + commentshow($($lines.get(comments[i].line)), comments[i]); } $lines.click(function (e) { @@ -129,7 +135,8 @@ function commentsave(uid, $form) { uid: uid, comment: comment, line: $form.parent().index(), - user: user + user: user, + token: commentToken }, function (data) { if (!data) { From 880e8ecc95337d4cfe25782b91b3590f46fdeb9a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 07:00:08 +0000 Subject: [PATCH 2/2] Modernize codebase to PHP 8.2 - declare(strict_types=1) in every PHP file. - Replace lib.php's procedural helpers with a final Token utility class (lib/Token.php) using a private constructor and static issue/verify. Class constant Token::TTL replaces the bare TOKEN_TTL global. - PasteManager: final class with constructor property promotion and a readonly $savedir, union return types (false|string, false|array), parameter type hints everywhere, named-arg friendly file_put_contents, and json-decoded comments filtered through is_array() to skip junk. - PasteManager::mkuid now uses random_bytes; drop the hardcoded salt and the dead call to the undefined gen_uuid(). Also generate a new uid on each collision retry (the original loop kept the same uid). - router.php: match() dispatch, never-returning fail() helper, per-case handler functions with typed signatures, $_GET vs $_POST chosen by REQUEST_METHOD, and HTTP error codes propagated via fail() so the status line and json body stay consistent. - index.php: htmlspecialchars() on the embedded save token (token is hex+digits today but defence in depth costs nothing) and short-echo. https://claude.ai/code/session_016Kh8s3xrcng9Dgd9Uui6PY --- index.php | 12 +++- lib/PasteManager.php | 143 ++++++++++++++++-------------------- lib/Token.php | 61 ++++++++++++++++ lib/lib.php | 53 -------------- lib/router.php | 167 +++++++++++++++++++++++++++++-------------- 5 files changed, 246 insertions(+), 190 deletions(-) create mode 100644 lib/Token.php delete mode 100644 lib/lib.php diff --git a/index.php b/index.php index 3b6e0da..d7a2aaa 100644 --- a/index.php +++ b/index.php @@ -1,9 +1,15 @@ + +declare(strict_types=1); + +require_once __DIR__ . '/lib/Token.php'; + +$saveToken = \splitbrain\paste\Token::issue('save'); +?> + - + commie ☭ diff --git a/lib/PasteManager.php b/lib/PasteManager.php index b363fd5..9000a40 100644 --- a/lib/PasteManager.php +++ b/lib/PasteManager.php @@ -1,130 +1,109 @@ savedir = __DIR__ . '/../data/'; - } + public function __construct( + private readonly string $savedir = __DIR__ . '/../data/', + ) {} /** - * Saves the given content under a new UID + * Save the given content under a freshly generated UID. * - * @param $content - * @return bool|string UID or false on error + * @return false|string the UID, or false on write failure */ - function savePaste($content) + public function savePaste(string $content): false|string { - $uid = $this->mkuid(); do { + $uid = $this->mkuid(); $path = $this->fn($uid, 'paste'); } while (file_exists($path)); @mkdir(dirname($path), 0777, true); - if(file_put_contents($path, $content)){ - return $uid; - } - return false; + return file_put_contents($path, $content) !== false ? $uid : false; } /** - * Loads a paste file - * - * @param $uid - * @return bool|string the paste's content, false on error + * @return false|string the paste's content, or false if it does not exist */ - function loadPaste($uid) { + public function loadPaste(string $uid): false|string + { $path = $this->fn($uid, 'paste'); - if(!file_exists($path)) return false; - return file_get_contents($path); + return file_exists($path) ? file_get_contents($path) : false; } /** - * Stores a comment + * Append a comment to the paste's comments file. * - * @param string $uid - * @param int $line - * @param string $comment - * @param string $user - * @return bool|array + * @return false|array the stored comment record, or false on failure */ - function saveComment($uid, $line, $comment, $user) { - $paste = $this->fn($uid, 'paste'); - if(!file_exists($paste)) return false; - - $path = $this->fn($uid, 'comments'); - - $data = compact('line', 'comment', 'user'); - $data['time'] = time(); - $data['color'] = substr(md5($user),0,6); - $json = json_encode($data); - if(file_put_contents($path, $json."\n", FILE_APPEND)) { - return $data; + public function saveComment(string $uid, int $line, string $comment, string $user): false|array + { + if (!file_exists($this->fn($uid, 'paste'))) { + return false; } - return false; + + $data = [ + 'line' => $line, + 'comment' => $comment, + 'user' => $user, + 'time' => time(), + 'color' => substr(md5($user), 0, 6), + ]; + + $written = file_put_contents( + $this->fn($uid, 'comments'), + json_encode($data) . "\n", + FILE_APPEND, + ); + return $written !== false ? $data : false; } /** - * Return all comments - * - * @param string $uid - * @return array|bool + * @return false|array list of comment records, or false if the paste does not exist */ - function loadComments($uid) { - $paste = $this->fn($uid, 'paste'); - if(!file_exists($paste)) return false; - - $data = array(); + public function loadComments(string $uid): false|array + { + if (!file_exists($this->fn($uid, 'paste'))) { + return false; + } $path = $this->fn($uid, 'comments'); - if(!file_exists($path)) return $data; - $json = file($path); - foreach($json as $line) { - $data[] = json_decode($line, true); + if (!file_exists($path)) { + return []; + } + + $out = []; + foreach (file($path) ?: [] as $line) { + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $out[] = $decoded; + } } - return $data; + return $out; } /** - * Get the full path to the file for the given UID and extension - * - * @param string $uid - * @param string $ext - * @return string + * Build the full path to the UID's file for the given extension. */ - public function fn($uid, $ext) + public function fn(string $uid, string $ext): string { - $prefix = substr($uid, 0, 1); - return $this->savedir . $prefix . '/' . $uid . '.' . $ext; + return $this->savedir . substr($uid, 0, 1) . '/' . $uid . '.' . $ext; } /** - * Creates a unique name - * - * @link http://stackoverflow.com/a/3537633/172068 - * @param int $len - * @return string + * Generate a random alphanumeric UID of the requested length. */ - public function mkuid($len = 8) + private function mkuid(int $len = 8): string { - - $hex = md5("yourSaltHere" . uniqid("", true)); - - $pack = pack('H*', $hex); - $tmp = base64_encode($pack); - - $uid = preg_replace("#(*UTF8)[^A-Za-z0-9]#", "", $tmp); - $len = max(4, min(128, $len)); - + $uid = ''; while (strlen($uid) < $len) { - $uid .= gen_uuid(22); + $uid .= preg_replace('#[^A-Za-z0-9]#', '', base64_encode(random_bytes(16))); } - return substr($uid, 0, $len); } - -} \ No newline at end of file +} diff --git a/lib/Token.php b/lib/Token.php new file mode 100644 index 0000000..d2f4410 --- /dev/null +++ b/lib/Token.php @@ -0,0 +1,61 @@ +:` + * keyed by a per-installation secret. Verification recomputes the HMAC against + * the scope passed at verification time, so a token issued for one action + * cannot be replayed against another. + */ +final class Token +{ + /** Maximum age of a token, in seconds. */ + public const TTL = 1800; + + private const SECRET_PATH = __DIR__ . '/../data/.secret'; + + private static ?string $secret = null; + + private function __construct() {} + + public static function issue(string $scope): string + { + $ts = time(); + return $ts . '.' . hash_hmac('sha256', $ts . ':' . $scope, self::secret()); + } + + public static function verify(string $scope, string $token): bool + { + if (!str_contains($token, '.')) { + return false; + } + [$ts, $hmac] = explode('.', $token, 2); + if (!ctype_digit($ts)) { + return false; + } + $expected = hash_hmac('sha256', $ts . ':' . $scope, self::secret()); + return hash_equals($expected, $hmac) + && abs(time() - (int) $ts) <= self::TTL; + } + + private static function secret(): string + { + if (self::$secret !== null) { + return self::$secret; + } + + if (!file_exists(self::SECRET_PATH)) { + $fresh = bin2hex(random_bytes(32)); + @file_put_contents(self::SECRET_PATH, $fresh, LOCK_EX); + @chmod(self::SECRET_PATH, 0600); + return self::$secret = $fresh; + } + + return self::$secret = trim((string) file_get_contents(self::SECRET_PATH)); + } +} diff --git a/lib/lib.php b/lib/lib.php deleted file mode 100644 index 2de1ded..0000000 --- a/lib/lib.php +++ /dev/null @@ -1,53 +0,0 @@ - TOKEN_TTL) return false; - return true; -} diff --git a/lib/router.php b/lib/router.php index 5be8d93..4255463 100644 --- a/lib/router.php +++ b/lib/router.php @@ -1,59 +1,122 @@ MAX_PASTE_BYTES) { http_response_code(413); break; } - if (!commie_token_valid('save', $token)) { http_response_code(403); break; } - $ret = $paste->savePaste($content); - break; - - case 'load': - $uid = commie_uid(isset($_GET['uid']) ? $_GET['uid'] : ''); - $ret = htmlspecialchars($paste->loadPaste($uid)); - break; - - case 'loadcomments': - $uid = commie_uid(isset($_GET['uid']) ? $_GET['uid'] : ''); - $ret = [ - 'comments' => $paste->loadComments($uid), - 'token' => commie_token($uid), - ]; - break; - - case 'savecomment': - if ($method !== 'POST') { http_response_code(405); break; } - $uid = commie_uid(isset($_POST['uid']) ? $_POST['uid'] : ''); - $line = isset($_POST['line']) ? (int) $_POST['line'] : 0; - $comment = isset($_POST['comment']) ? $_POST['comment'] : ''; - $user = isset($_POST['user']) ? $_POST['user'] : ''; - $token = isset($_POST['token']) ? $_POST['token'] : ''; - if (strlen($comment) > MAX_COMMENT_BYTES) { http_response_code(413); break; } - if (strlen($user) > MAX_USER_BYTES) { http_response_code(413); break; } - if (!commie_token_valid($uid, $token)) { http_response_code(403); break; } - $commentsPath = $paste->fn($uid, 'comments'); - if (file_exists($commentsPath) && (time() - filemtime($commentsPath)) < COMMENT_DELAY) { - http_response_code(429); - break; - } - $ret = $paste->saveComment($uid, $line, $comment, $user); - break; - - default: - $ret = false; +require_once __DIR__ . '/PasteManager.php'; +require_once __DIR__ . '/Token.php'; + +const MAX_PASTE_BYTES = 524288; // 512 KB +const MAX_COMMENT_BYTES = 4096; // 4 KB +const MAX_USER_BYTES = 64; +const COMMENT_DELAY = 3; // seconds, per-paste cooldown for savecomment + +/** + * Send an error status with a json `false` body and stop. + */ +function fail(int $code): never +{ + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode(false); + exit; +} + +/** + * Strip everything that isn't an ASCII alphanumeric from a uid candidate. + */ +function clean_uid(mixed $raw): string +{ + return preg_replace('#(*UTF8)[^A-Za-z0-9]#', '', (string) ($raw ?? '')); } +$paste = new PasteManager(); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$do = match ($method) { + 'POST' => (string) ($_POST['do'] ?? ''), + default => (string) ($_GET['do'] ?? ''), +}; + +$ret = match ($do) { + 'save' => handle_save($paste), + 'load' => handle_load($paste), + 'loadcomments' => handle_loadcomments($paste), + 'savecomment' => handle_savecomment($paste), + default => false, +}; + header('Content-Type: application/json'); echo json_encode($ret); + +function handle_save(PasteManager $paste): false|string +{ + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + fail(405); + } + $content = (string) ($_POST['content'] ?? ''); + if (strlen($content) > MAX_PASTE_BYTES) { + fail(413); + } + if (!Token::verify('save', (string) ($_POST['token'] ?? ''))) { + fail(403); + } + return $paste->savePaste($content); +} + +function handle_load(PasteManager $paste): false|string +{ + $uid = clean_uid($_GET['uid'] ?? null); + if ($uid === '') { + return false; + } + $content = $paste->loadPaste($uid); + return $content === false ? false : htmlspecialchars($content); +} + +function handle_loadcomments(PasteManager $paste): false|array +{ + $uid = clean_uid($_GET['uid'] ?? null); + if ($uid === '') { + return false; + } + $comments = $paste->loadComments($uid); + if ($comments === false) { + return false; + } + return [ + 'comments' => $comments, + 'token' => Token::issue($uid), + ]; +} + +function handle_savecomment(PasteManager $paste): false|array +{ + if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { + fail(405); + } + $uid = clean_uid($_POST['uid'] ?? null); + $line = (int) ($_POST['line'] ?? 0); + $comment = (string) ($_POST['comment'] ?? ''); + $user = (string) ($_POST['user'] ?? ''); + + if ($uid === '') { + fail(400); + } + if (strlen($comment) > MAX_COMMENT_BYTES) { + fail(413); + } + if (strlen($user) > MAX_USER_BYTES) { + fail(413); + } + if (!Token::verify($uid, (string) ($_POST['token'] ?? ''))) { + fail(403); + } + + $commentsPath = $paste->fn($uid, 'comments'); + if (file_exists($commentsPath) && (time() - filemtime($commentsPath)) < COMMENT_DELAY) { + fail(429); + } + + return $paste->saveComment($uid, $line, $comment, $user); +}