manager = $manager; $this->enabled = $enabled && $manager !== null; $this->legacySecret = $legacySecret; $this->legacyExpiry = $legacyExpiry; } /** * @return bool True when tokens will actually be issued and checked. */ public function isEnabled() : bool { return $this->enabled; } /** * Issue a token for the current session. * * Symfony returns a freshly randomised encoding of one stored secret on * every call, so successive calls return different strings that all * validate. That is deliberate BREACH mitigation. * * @return string The token, or an empty string when the guard is disabled. */ public function token() : string { if (!$this->enabled || $this->manager === null) { return ''; } return $this->manager->getToken(self::INTENTION_GLOBAL)->getValue(); } /** * @param string $value The submitted token. * * @return bool True only when the token is valid for this session. */ public function validate(string $value) : bool { if (!$this->enabled || $this->manager === null || $value === '') { return false; } if ($this->manager->isTokenValid(new CsrfToken(self::INTENTION_GLOBAL, $value))) { return true; } return $this->validateLegacyHmac($value); } /** * @param string $url URL of the XHR-patching script to inject. */ public function setScriptUrl(string $url) : void { $this->scriptUrl = $url; } /** * Supply a CSP nonce for the scripts this class injects. * * develop currently sends 'unsafe-inline', so this is unused today. 1.2.x * has already moved to nonce-based CSP; when develop follows, the injected * scripts would silently stop running and take every AJAX call with them. * * @param string $nonce The per-request nonce, or an empty string for none. */ public function setNonce(string $nonce) : void { $this->nonce = $nonce; } /** * Inject the CSRF token into POST forms and the XHR shim into the page. * * The form pattern is carried over from csrf-magic unchanged. It is * deliberately not widened: every POST is validated, decorated or not, so * a form this pattern misses already fails visibly rather than silently. * * The handler may be called repeatedly for a chunked response, so the * "have we seen enabled) { return $buffer; } if (!$this->isHtml) { $this->isHtml = (stripos($buffer, 'isHtml) { return $buffer; } $rawToken = $this->token(); $token = htmlspecialchars($rawToken, ENT_QUOTES, 'UTF-8'); $name = self::INPUT_NAME; $input = ""; $buffer = preg_replace('#(]*method\s*=\s*["\']post["\'][^>]*>)#i', '$1' . $input, $buffer) ?? $buffer; if ($this->scriptUrl === '') { return $buffer; } $attr = ($this->nonce !== '' ? ' nonce="' . htmlspecialchars($this->nonce, ENT_QUOTES, 'UTF-8') . '"' : ''); $url = htmlspecialchars($this->scriptUrl, ENT_QUOTES, 'UTF-8'); /* * The hidden field above is an HTML attribute, so htmlspecialchars() is * correct there. These two values land inside a ' . ''; $buffer = str_ireplace('', $head, $buffer); $end = ''; $count = 0; $buffer = str_ireplace('', $end . '', $buffer, $count); if (!$count) { $buffer .= $end; } return $buffer; } /** * Accept a csrf-magic token minted before the upgrade. * * Only the 'sid:' form is honoured. Cacti configured csrf-magic in session * mode, so its cookie, key, user and ip branches were unreachable and are * not ported. The token carries its own issue time, so this path expires * on its own arithmetic and needs no configuration flag. * * Remove this method, and the secret plumbing behind it, once the upgrade * window has passed. See phase 2 of the design document. * * @param string $value The submitted token. * * @return bool True when the token is a live pre-upgrade token. */ private function validateLegacyHmac(string $value) : bool { if ($this->legacySecret === '' || strpos($value, 'sid:') !== 0) { return false; } $body = substr($value, 4); if (strpos($body, ',') === false) { return false; } [$hash, $time] = explode(',', $body, 2); if (!ctype_digit($time)) { return false; } if (time() >= (int) $time + $this->legacyExpiry) { return false; } $inner = hash_hmac(self::LEGACY_HASH, $time . ':' . session_id(), $this->legacySecret); $expected = hash_hmac(self::LEGACY_HASH, $inner, $this->legacySecret); return hash_equals($expected, $hash); } }