getContent() in * Symfony, $request->getContent() in Laravel) -- NOT the parsed array. * * 2. Re-encoding what you parsed. json_encode(json_decode($raw)) is a * DIFFERENT byte string from $raw: PHP escapes every "/" as "\/" and * every non-ASCII character as \uXXXX unless you remember two flags, and * incident titles have plenty of both. Sign the bytes that arrived. * * 3. Comparing with == or ===. Use hash_equals. A comparison that returns on * the first differing byte leaks the correct signature to anyone who can * time it -- and in PHP, == on two strings that both look numeric has * historically had a second, louder problem on top of the timing one. */ declare(strict_types=1); // Read from the environment, never pasted into the file. This is the one value // that lets anyone forge a message your receiver will believe. $secret = getenv('KESVARA_SIGNING_KEY'); if ($secret === false || $secret === '') { // error_log, not fwrite(STDERR, ...): the STDERR constant exists only in // the CLI SAPI, so the tidy-looking version of this line is itself a fatal // error under php-fpm -- while you are trying to report a different one. error_log('KESVARA_SIGNING_KEY is not set'); http_response_code(500); echo json_encode(['refused' => 'receiver is misconfigured']); exit(1); } const SIG_HEADER = 'HTTP_X_KESVARA_SIGNATURE'; // X-Kesvara-Signature const TS_HEADER = 'HTTP_X_KESVARA_TIMESTAMP'; // X-Kesvara-Timestamp const VERSION = 'v1'; // How much clock skew you will tolerate, in seconds. Kesvara cannot enforce // this for you -- it is your end of the exchange. Five minutes is a normal // choice: long enough to survive an unsynchronised clock, short enough that a // captured message is not replayable tomorrow. const TOLERANCE_SECONDS = 300; /** * Returns null if the message is genuine, or a reason to refuse it. * * A reason rather than a bool because the reason belongs in your log. "Refused * a webhook" at 4am with no reason is a support ticket. * * $now is unix seconds, a parameter so a test can move the clock without * waiting for it. */ function verify(string $secret, string $ts, string $sig, string $body, int $now): ?string { if ($sig === '' || $ts === '') { return 'missing signature headers'; } // An explicit version check. If Kesvara ever signs a different shape of // string it will say v2, and a receiver written against v1 must refuse it // rather than compute a v1 signature over it and quietly disagree. if (!str_starts_with($sig, VERSION . '=')) { return 'unknown signature version'; } // preg_match rather than is_numeric or ctype_digit. is_numeric accepts // "1e9", " 12" and "0x1A"; ctype_digit rejects a leading "-". Both would // hand the line below a string the signature step and the clock step read // differently, which is how one input acquires two spellings. if (!preg_match('/^-?[0-9]+$/', $ts)) { return 'malformed timestamp'; } // THE TIMESTAMP IS SIGNED AS THE EXACT CHARACTERS THAT ARRIVED, not as a // number that has been through (int) and back. Those differ for "0173" and // "173", and a receiver that rebuilds this string from the parsed integer // will accept both spellings of one message. There is a control receiver // making exactly that mistake in tests/bad_receivers/, put through this // same battery, so the rule is proven rather than asserted. $signed = VERSION . ':' . $ts . ':' . $body; $expected = VERSION . '=' . hash_hmac('sha256', $signed, $secret); // hash_equals, not ===. See mistake 3 at the top of the file. if (!hash_equals($expected, $sig)) { return 'signature mismatch'; } // Freshness is checked AFTER the signature, deliberately. Before it, you // would be making a decision about a header that nothing has authenticated // yet. After it, the timestamp is known to be the one Kesvara signed -- // which is what makes rejecting an old one a real defence against a // replayed message rather than a formality. It also keeps the refusal // reason from telling a forger which of the two checks they got past. $age = $now - (int) $ts; if ($age > TOLERANCE_SECONDS) { return 'timestamp too old'; } if ($age < -TOLERANCE_SECONDS) { return 'timestamp too far in the future'; } return null; } function refuse(int $status, string $why): void { // Log the reason, never the key and never the expected signature -- // printing what you expected hands a forger the answer, and the same string // usually ends up in a log a whole team can read. error_log("refused a Kesvara webhook: {$why}"); http_response_code($status); header('Content-Type: application/json'); echo json_encode(['refused' => $why]); } /** * Yours to write. This is what the payload contains. * * NOTE THE error_log. This is the one place the PHP example has to differ from * the Python and Node ones, and it is the difference most likely to bite you. * `print` in Python and `console.log` in Node go to your console; `echo` and * `printf` in PHP go INTO THE RESPONSE BODY, under every SAPI including the * built-in server. So an incident printed the obvious way is prepended to the * JSON reply, and Kesvara reads the delivery as failed and retries it -- which * looks like a network problem and is a print statement. */ function handle_incident(array $incident): void { error_log(sprintf('incident %d: %s (%d alerts)', $incident['id'], $incident['title'], $incident['alert_count'])); $similar = $incident['similar'] ?? null; if ($similar === null) { error_log(" no similar resolved incident in this workspace's history"); return; } // Kesvara found an older incident of yours that looks like this one. // action_item is a sentence one of your team typed at the time. It is not // a diagnosis and Kesvara does not claim it is one. error_log(sprintf(' similar to %s: %s', $similar['date'], $similar['action_item'])); if (!($similar['observed_by_kesvara'] ?? true)) { error_log(' (from history your team imported, not one Kesvara saw)'); } if (!($similar['matched_by_shared_wording'] ?? true)) { error_log(' (linked by a vocabulary entry somebody on your team wrote)'); } } // --------------------------------------------------------------------------- // The request // --------------------------------------------------------------------------- if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { header('Allow: POST'); refuse(405, 'POST only'); exit; } // RAW bytes. Not $_POST, and not json_decode'd first -- see mistakes 1 and 2. $body = file_get_contents('php://input'); if ($body === false) { refuse(400, 'unreadable body'); exit; } $why = verify( $secret, $_SERVER[TS_HEADER] ?? '', $_SERVER[SIG_HEADER] ?? '', $body, time() ); if ($why !== null) { refuse(401, $why); exit; } $message = json_decode($body, true); if (!is_array($message)) { // Only reachable from something holding your signing key, so this is a bug // report rather than an attack. refuse(400, 'signed body was not JSON'); exit; } if (($message['event'] ?? '') === 'incident.opened' && isset($message['incident'])) { // Kesvara retries a failed delivery, and a retry re-sends the SAME bytes // with the same timestamp and the same signature. If this does something // you would not want done twice -- paging someone, opening a ticket -- key // it on incident['id'], which is stable across retries. Do not key it on // the signature, which is stable too and will therefore also suppress a // genuine second delivery after a key rotation. handle_incident($message['incident']); } http_response_code(200); header('Content-Type: application/json'); echo json_encode(['ok' => true]);