'log', 'crs' => 'log', 'emergency' => 'log', 'vpatch' => 'off', 'ip' => 'off', 'rate' => 'off', 'response' => 'off', ); const MODES = array( 'off', 'log', 'block' ); /** Event sources the contract names; an unknown one is kept (additive) but labelled as is. */ const SOURCES = array( 'shape', 'crs', 'emergency', 'vpatch', 'ip', 'rate', 'response', 'guard' ); /** * Test-only clock. Null in production. * * @var int|null */ public static $now_override = null; /** * Test-only event store. Null in production. * * @var object|null */ public static $store_override = null; /** * Test-only sampler: returns true to sample this request. Null in production. * * @var callable|null */ public static $sample_override = null; /** * Decrypted secrets for this request. * * @var array */ private static $secrets = array(); /** * forward_status() for this request, once worked out. * * @var string|null */ private static $forward = null; /** * The response the edge route built, for serve_raw(): array( data ), data null for an * empty body. Null when no edge route ran in this request. * * @var array|null */ private static $raw = null; /** Current time. */ public static function now(): int { return null !== self::$now_override ? self::$now_override : time(); } /** The event store. */ public static function store() { return null !== self::$store_override ? self::$store_override : new CSDT_Edge_Event_Store(); } /** Forgets per-request memos. For the tests, which run many requests in one process. */ public static function reset_request_state(): void { self::$secrets = array(); self::$forward = null; self::$raw = null; } // ── Pairing ──────────────────────────────────────────────────────── /** * The registry, normalised. * * @return array{keys:array,sample:int} */ public static function registry(): array { return self::normalise_registry( get_option( self::OPTION_GATEWAYS, array() ) ); } /** * The registry for a front-end request: read only when the row is among the autoloaded * options already in memory, so an unpaired site pays no query at all. * * @return array{keys:array,sample:int} */ private static function registry_front(): array { $all = function_exists( 'wp_load_alloptions' ) ? wp_load_alloptions() : array(); if ( ! is_array( $all ) || ! array_key_exists( self::OPTION_GATEWAYS, $all ) ) { return self::normalise_registry( array() ); } return self::registry(); } /** * Normalises a stored registry. * * @param mixed $raw Stored value. * @return array{keys:array,sample:int} */ private static function normalise_registry( $raw ): array { $raw = is_array( $raw ) ? $raw : array(); $keys = array(); foreach ( (array) ( $raw['keys'] ?? array() ) as $id => $k ) { if ( is_array( $k ) && 1 === preg_match( CSDT_Edge_Sig::KEY_ID_RE, (string) $id ) ) { $keys[ (string) $id ] = array( 'secret' => (string) ( $k['secret'] ?? '' ), 'created' => (int) ( $k['created'] ?? 0 ), 'label' => (string) ( $k['label'] ?? '' ), ); } } $sample = (int) ( $raw['sample'] ?? self::DEFAULT_SAMPLE ); return array( 'keys' => $keys, 'sample' => max( 1, min( 1000, $sample ) ), ); } /** Whether any gateway is paired. */ public static function is_paired(): bool { return array() !== self::registry()['keys']; } /** * A paired key's raw secret, '' when unknown or unreadable on this copy (salts rotated, or a * standby restored from another site's database: such a copy must not answer as the primary). * * @param string $key_id Key id. * @return string */ public static function secret( string $key_id ): string { if ( array_key_exists( $key_id, self::$secrets ) ) { return self::$secrets[ $key_id ]; } $keys = self::registry()['keys']; $plain = isset( $keys[ $key_id ] ) && '' !== $keys[ $key_id ]['secret'] ? CSDT_Secrets::decrypt( $keys[ $key_id ]['secret'] ) : null; $out = is_string( $plain ) && CSDT_Edge_Sig::SECRET_BYTES === strlen( $plain ) ? $plain : ''; self::$secrets[ $key_id ] = $out; return $out; } /** * Pairs a new gateway. The token is returned once and never stored. * * @param string $label Admin's note, optional. * @param string $admin_ip The pairing admin's address, remembered as an admin IP for this key. * @return array{ok:bool,error:string,key_id:string,token:string} */ public static function pair( string $label = '', string $admin_ip = '' ): array { $out = array( 'ok' => false, 'error' => '', 'key_id' => '', 'token' => '', ); $reg = self::registry(); if ( count( $reg['keys'] ) >= self::MAX_GATEWAYS ) { /* translators: %d: the most gateways one site may pair */ $out['error'] = sprintf( __( 'This site already has %d gateways paired, the most it may. Unpair one first.', 'cloudscale-cyber-devtools' ), self::MAX_GATEWAYS ); return $out; } do { $key_id = 'gw' . bin2hex( random_bytes( 5 ) ); } while ( isset( $reg['keys'][ $key_id ] ) ); $secret = random_bytes( CSDT_Edge_Sig::SECRET_BYTES ); $enc = CSDT_Secrets::encrypt( $secret ); if ( '' === $enc ) { $out['error'] = __( 'The secret cannot be stored encrypted on this site (no usable CSDT_SECRETS_KEY or salts, or no sodium). Nothing was paired.', 'cloudscale-cyber-devtools' ); return $out; } $reg['keys'][ $key_id ] = array( 'secret' => $enc, 'created' => self::now(), 'label' => substr( sanitize_text_field( $label ), 0, 60 ), ); update_option( self::OPTION_GATEWAYS, $reg, true ); self::$secrets[ $key_id ] = $secret; self::store()->install(); if ( '' !== $admin_ip ) { self::remember_admin_ip( $admin_ip ); } self::activity( 'edge_gateway_paired', $key_id, '' !== $reg['keys'][ $key_id ]['label'] ? 'Label: ' . $reg['keys'][ $key_id ]['label'] : '' ); $out['ok'] = true; $out['key_id'] = $key_id; $out['token'] = CSDT_Edge_Sig::token( $key_id, $secret ); return $out; } /** * Unpairs a gateway: its secret is deleted, so its token can never work again. Unpairing * the last one removes the registry row, which makes the whole feature dormant again. * * @param string $key_id Key id. * @return bool Whether it was paired. */ public static function unpair( string $key_id ): bool { $reg = self::registry(); if ( ! isset( $reg['keys'][ $key_id ] ) ) { return false; } unset( $reg['keys'][ $key_id ] ); if ( array() === $reg['keys'] ) { delete_option( self::OPTION_GATEWAYS ); } else { update_option( self::OPTION_GATEWAYS, $reg, true ); } unset( self::$secrets[ $key_id ] ); foreach ( array( self::OPTION_ADMIN_IPS, self::OPTION_STATE_META ) as $opt ) { $v = get_option( $opt, array() ); if ( is_array( $v ) && isset( $v[ $key_id ] ) ) { unset( $v[ $key_id ] ); update_option( $opt, $v, false ); } } self::activity( 'edge_gateway_unpaired', $key_id, '' ); return true; } /** * Unpairs every gateway. For the QA re-pair script, which starts each morning from a * database copied over from production. * * @return string[] The key ids that were unpaired. */ public static function unpair_all(): array { $ids = array_keys( self::registry()['keys'] ); foreach ( $ids as $id ) { self::unpair( $id ); } return $ids; } /** * What `wp csdt edge status` prints: one row per paired key, plus the modes and the last * heartbeat, from stored data only. * * @return array{keys:array>,modes:array,last_heartbeat:int,uploads_confirmed:bool} */ public static function status_summary(): array { $seen = self::last_seen(); $rows = array(); foreach ( self::registry()['keys'] as $id => $k ) { $rows[] = array( 'key_id' => $id, 'label' => $k['label'], 'paired' => $k['created'], 'last_state' => (int) ( $seen[ $id ]['state'] ?? 0 ), 'last_events' => (int) ( $seen[ $id ]['events'] ?? 0 ), 'readable' => '' !== self::secret( $id ), ); } $s = self::settings(); return array( 'keys' => $rows, 'modes' => $s['modes'], 'last_heartbeat' => self::last_heartbeat(), 'uploads_confirmed' => $s['uploads']['confirmed'], ); } // ── Settings ─────────────────────────────────────────────────────── /** * Settings, normalised. * * @return array{modes:array,uploads:array{confirmed:bool,paths:array}} */ public static function settings(): array { $raw = get_option( self::OPTION_SETTINGS, array() ); $raw = is_array( $raw ) ? $raw : array(); $modes = self::DEFAULT_MODES; foreach ( (array) ( $raw['modes'] ?? array() ) as $f => $m ) { if ( isset( $modes[ $f ] ) && in_array( $m, self::MODES, true ) ) { $modes[ $f ] = (string) $m; } } $up = is_array( $raw['uploads'] ?? null ) ? $raw['uploads'] : array(); $confirmed = ! empty( $up['confirmed'] ); // Shape may block only after the upload paths are confirmed. Re-checked on read, so an // edited option cannot skip the confirmation. if ( 'block' === $modes['shape'] && ! $confirmed ) { $modes['shape'] = 'log'; } return array( 'modes' => $modes, 'uploads' => array( 'confirmed' => $confirmed, 'paths' => self::clean_paths( (array) ( $up['paths'] ?? array() ) ), ), ); } /** * Validated upload paths: a leading slash, no control characters, exact or prefix. * * @param array $paths Raw. * @return array */ public static function clean_paths( array $paths ): array { $out = array(); $seen = array(); foreach ( $paths as $p ) { $path = is_array( $p ) ? (string) ( $p['path'] ?? '' ) : (string) $p; $match = is_array( $p ) && 'prefix' === ( $p['match'] ?? '' ) ? 'prefix' : 'exact'; $path = trim( $path ); if ( '' === $path || '/' !== $path[0] || strlen( $path ) > 512 || 1 === preg_match( '/[\x00-\x20\x7f?#]/', $path ) ) { continue; } if ( 'prefix' === $match && '/' === $path ) { continue; // A prefix of "/" would allow multipart everywhere: that is Off, not a path. } $k = $match . ' ' . $path; if ( isset( $seen[ $k ] ) || count( $out ) >= self::MAX_UPLOAD_PATHS ) { continue; } $seen[ $k ] = true; $out[] = array( 'path' => $path, 'match' => $match, ); } return $out; } /** * Saves modes. Shape to Block is refused until the upload paths are confirmed. * * @param array $requested feature => mode. * @return array{ok:bool,error:string,modes:array} */ public static function save_modes( array $requested ): array { $s = self::settings(); $modes = $s['modes']; $err = ''; foreach ( self::FEATURES as $f ) { if ( ! isset( $requested[ $f ] ) ) { continue; } $m = (string) $requested[ $f ]; if ( ! in_array( $m, self::MODES, true ) ) { continue; } if ( 'shape' === $f && 'block' === $m && ! $s['uploads']['confirmed'] ) { $err = __( 'Shape stays in Log until you confirm the upload paths below. Block before that would refuse uploads the site needs.', 'cloudscale-cyber-devtools' ); continue; } $modes[ $f ] = $m; } $raw = get_option( self::OPTION_SETTINGS, array() ); $raw = is_array( $raw ) ? $raw : array(); $raw['modes'] = $modes; update_option( self::OPTION_SETTINGS, $raw, false ); self::activity( 'edge_gateway_settings', 'modes', self::modes_text( $modes ) ); return array( 'ok' => '' === $err, 'error' => $err, 'modes' => self::settings()['modes'], ); } /** * Confirms the upload paths: the admin's list replaces the stored one, and shape may then * be set to Block. * * @param array $paths Paths, each a string or {path, match}. * @return array What was stored. */ public static function confirm_uploads( array $paths ): array { $clean = self::clean_paths( $paths ); $raw = get_option( self::OPTION_SETTINGS, array() ); $raw = is_array( $raw ) ? $raw : array(); $raw['uploads'] = array( 'confirmed' => true, 'paths' => $clean, 'confirmed_at' => self::now(), ); update_option( self::OPTION_SETTINGS, $raw, false ); self::activity( 'edge_gateway_settings', 'upload paths', count( $clean ) . ' confirmed path(s) beside the WordPress defaults' ); return $clean; } /** Modes as one line, for the activity log. */ private static function modes_text( array $modes ): string { $parts = array(); foreach ( $modes as $f => $m ) { $parts[] = $f . ' ' . $m; } return implode( ', ', $parts ); } /** * A path on this site, from a WordPress URL (subdirectory installs keep their prefix). * * @param string $url URL. * @return string */ private static function url_path( string $url ): string { $p = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url, PHP_URL_PATH ) : parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback when core is not loaded return is_string( $p ) && '' !== $p ? $p : '/'; } /** * The upload paths WordPress itself uses: the media uploader, the media screen, plugin and * theme zip uploads, and the block editor's REST media route. * * @return array */ public static function default_upload_paths(): array { $out = array(); foreach ( array( 'async-upload.php', 'media-new.php', 'update.php' ) as $f ) { $out[] = array( 'path' => self::url_path( admin_url( $f ) ), 'match' => 'exact', ); } $out[] = array( 'path' => rtrim( self::url_path( rest_url( 'wp/v2/media' ) ), '/' ), 'match' => 'prefix', ); return $out; } /** Defaults plus the admin's confirmed paths. */ public static function upload_paths(): array { return self::clean_paths( array_merge( self::default_upload_paths(), self::settings()['uploads']['paths'] ) ); } // ── State (section 4) ────────────────────────────────────────────── /** * The state for one key, without generated_at (which is when it last changed). * * @param string $key_id Key id: admin IP hashes are keyed by its secret. * @return array */ public static function build_state( string $key_id ): array { $s = self::settings(); $lists = self::ip_lists(); $max_up = function_exists( 'wp_max_upload_size' ) ? (int) wp_max_upload_size() : 0; $lockdown = class_exists( 'CSDT_Lockdown' ) && CSDT_Lockdown::is_engaged() && ! CSDT_Lockdown::suspended(); return array( 'format' => 1, 'generated_at' => '', 'modes' => $s['modes'], 'lockdown' => $lockdown, 'ip' => array( 'block' => $lists['block'], 'allow' => $lists['allow'], 'admin_ip_hashes' => self::admin_ip_hashes( $key_id ), ), 'uploads' => array( 'paths' => self::upload_paths(), // Generous: the cap applies to every request, not only uploads, and a body cap // that refuses an ordinary editor save is a bigger fault than the one it stops. 'max_body_bytes' => max( 67108864, $max_up + 1048576 ), 'max_parts' => 50, 'max_headers_per_part' => 8, 'max_name_depth' => 8, ), 'software' => self::software(), 'session' => array( 'cookie_names' => self::cookie_names() ), ); } /** * Block and allow lists from the plugin's own stores, newest blocks first, truncated oldest * first to the contract caps. * * @return array{block:array,allow:array,dropped_block:int,dropped_allow:int} */ public static function ip_lists(): array { $now = self::now(); $list = get_option( 'csdt_ip_blocklist', array() ); $rows = array(); foreach ( is_array( $list ) ? $list : array() as $raw_ip => $entry ) { $cidr = self::cidr( (string) $raw_ip ); if ( '' === $cidr ) { continue; } if ( class_exists( 'CSDT_Auto_Block' ) ) { $e = CSDT_Auto_Block::normalise_entry( $entry, (string) $raw_ip ); if ( CSDT_Auto_Block::entry_has_expired( $e, $now ) ) { continue; } } else { $e = array( 'blocked_at' => (int) ( is_array( $entry ) ? ( $entry['blocked_at'] ?? 0 ) : 0 ), 'expires_at' => (int) ( is_array( $entry ) ? ( $entry['expires_at'] ?? 0 ) : 0 ), 'source' => 'manual', 'signal' => '', ); if ( $e['expires_at'] > 0 && $e['expires_at'] <= $now ) { continue; } } $reason = 'auto' === $e['source'] ? 'auto-block' . ( '' !== $e['signal'] ? ': ' . $e['signal'] : '' ) : 'manual'; $rows[] = array( 'at' => (int) $e['blocked_at'], 'row' => array( 'cidr' => $cidr, 'until' => (int) $e['expires_at'] > 0 ? gmdate( 'Y-m-d\TH:i:s\Z', (int) $e['expires_at'] ) : null, 'reason' => substr( preg_replace( '/[^a-z0-9 :_-]/i', '', $reason ), 0, 64 ), ), ); } usort( $rows, static fn( $a, $b ) => $b['at'] <=> $a['at'] ); $block = array_map( static fn( $r ) => $r['row'], array_slice( $rows, 0, self::MAX_BLOCK ) ); $allow = array(); $raw = class_exists( 'CSDT_Auto_Block' ) ? (string) get_option( CSDT_Auto_Block::OPTION_ALLOWLIST, '' ) : ''; foreach ( (array) preg_split( '/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY ) as $entry ) { $cidr = self::cidr( (string) $entry ); if ( '' !== $cidr && ! in_array( $cidr, $allow, true ) ) { $allow[] = $cidr; } } return array( 'block' => $block, 'allow' => array_slice( $allow, 0, self::MAX_ALLOW ), 'dropped_block' => max( 0, count( $rows ) - self::MAX_BLOCK ), 'dropped_allow' => max( 0, count( $allow ) - self::MAX_ALLOW ), ); } /** * An address or CIDR in the form the gateway parses, '' when invalid. * * @param string $s Address or CIDR. * @return string */ public static function cidr( string $s ): string { $s = trim( $s ); if ( false === strpos( $s, '/' ) ) { $ip = CSDT_Edge_Sig::canonical_ip( $s ); if ( '' === $ip ) { return ''; } return $ip . ( false === strpos( $ip, ':' ) ? '/32' : '/128' ); } list( $net, $bits ) = explode( '/', $s, 2 ); $net = CSDT_Edge_Sig::canonical_ip( $net ); if ( '' === $net || 1 !== preg_match( '/^\d{1,3}$/', $bits ) ) { return ''; } $max = false === strpos( $net, ':' ) ? 32 : 128; return (int) $bits <= $max ? $net . '/' . (int) $bits : ''; } /** * Installed core, web PHP, plugins and themes. * * @return array{core:string,php:string,plugins:array,themes:array} */ public static function software(): array { global $wp_version; $plugins = array(); if ( ! function_exists( 'get_plugins' ) && function_exists( 'wp_get_themes' ) ) { // Only inside WordPress (wp_get_themes is core): the REST route runs before wp-admin loads. require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( function_exists( 'get_plugins' ) ) { foreach ( (array) get_plugins() as $file => $data ) { $slug = false !== strpos( (string) $file, '/' ) ? dirname( (string) $file ) : basename( (string) $file, '.php' ); $plugins[] = array( 'slug' => (string) $slug, 'version' => (string) ( $data['Version'] ?? '' ), ); } } $themes = array(); if ( function_exists( 'wp_get_themes' ) ) { foreach ( (array) wp_get_themes() as $slug => $theme ) { $themes[] = array( 'slug' => (string) $slug, 'version' => is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Version' ) : '', ); } } // The web server's PHP, which is what this route runs under; CLI is never reported. $php = class_exists( 'CSDT_Php_Critical' ) ? CSDT_Php_Critical::version() : ''; return array( 'core' => is_string( $wp_version ) ? $wp_version : '', 'php' => $php, 'plugins' => $plugins, 'themes' => $themes, ); } /** * The site's session cookie names: WordPress's auth cookies and PHP's session cookie. * * @return string[] */ public static function cookie_names(): array { $out = array(); foreach ( array( 'AUTH_COOKIE', 'SECURE_AUTH_COOKIE', 'LOGGED_IN_COOKIE' ) as $c ) { if ( defined( $c ) ) { $out[] = (string) constant( $c ); } } $sess = (string) ini_get( 'session.name' ); $out[] = '' !== $sess ? $sess : 'PHPSESSID'; return array_values( array_unique( $out ) ); } /** * The state body and ETag for one key. generated_at moves only when the content does, so * an unchanged site answers the gateway's If-None-Match with 304. * * @param string $key_id Key id. * @return array{body:string,etag:string,dropped:int} */ public static function state_body( string $key_id ): array { $state = self::build_state( $key_id ); $dropped = 0; $json = self::encode( $state ); // The gateway refuses a state over 2 MiB. Drop the oldest blocks until it fits. $size = strlen( $json ); while ( $size > self::CAP_STATE && array() !== $state['ip']['block'] ) { $excess = $size - self::CAP_STATE; $n = max( 1, min( count( $state['ip']['block'] ), (int) ceil( $excess / 60 ) ) ); array_splice( $state['ip']['block'], -$n ); $dropped += $n; $json = self::encode( $state ); $size = strlen( $json ); } $hash = hash( 'sha256', $json ); $meta = get_option( self::OPTION_STATE_META, array() ); $meta = is_array( $meta ) ? $meta : array(); $mine = is_array( $meta[ $key_id ] ?? null ) ? $meta[ $key_id ] : array(); if ( ( $mine['hash'] ?? '' ) !== $hash || $dropped !== (int) ( $mine['dropped'] ?? 0 ) ) { $mine = array( 'hash' => $hash, 'generated_at' => self::now(), 'dropped' => $dropped, 'blocks' => count( $state['ip']['block'] ), ); $meta[ $key_id ] = $mine; update_option( self::OPTION_STATE_META, $meta, false ); } $state['generated_at'] = gmdate( 'Y-m-d\TH:i:s\Z', (int) $mine['generated_at'] ); return array( 'body' => self::encode( $state ), 'data' => $state, 'etag' => '"' . substr( $hash, 0, 16 ) . '"', 'dropped' => $dropped, ); } /** * JSON for the wire. Lists stay lists and an empty modes map stays an object. * * @param mixed $v Value. * @return string */ private static function encode( $v ): string { $s = function_exists( 'wp_json_encode' ) ? wp_json_encode( $v, JSON_UNESCAPED_SLASHES ) : json_encode( $v, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- fallback when core is not loaded return is_string( $s ) ? $s : '{}'; } // ── Administrator addresses (section 3.4) ────────────────────────── /** * Hooked to wp_login: remembers an administrator's address, as one HMAC per paired key, so * no address is ever stored or sent in clear. Nothing at all while no gateway is paired. * * @param mixed $_login Username, unused. * @param mixed $user WP_User. * @return void */ public static function on_login( $_login = '', $user = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundBeforeLastUsed -- wp_login passes the username first try { if ( array() === self::registry_front()['keys'] ) { return; } if ( ! is_object( $user ) || ! method_exists( $user, 'has_cap' ) || ! $user->has_cap( 'manage_options' ) ) { return; } self::remember_admin_ip( (string) CSDT_DevTools::get_client_ip() ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Stores an administrator's address hashed under every paired key, pruning old ones. * * @param string $ip Address. * @return void */ public static function remember_admin_ip( string $ip ): void { $now = self::now(); $all = get_option( self::OPTION_ADMIN_IPS, array() ); $all = is_array( $all ) ? $all : array(); $keys = self::registry()['keys']; $out = array(); foreach ( array_keys( $keys ) as $key_id ) { $seen = is_array( $all[ $key_id ] ?? null ) ? $all[ $key_id ] : array(); $sec = self::secret( $key_id ); $h = '' === $sec ? null : CSDT_Edge_Sig::admin_ip_hash( $sec, $ip ); if ( null !== $h ) { $seen[ $h ] = $now; } foreach ( $seen as $k => $at ) { if ( $now - (int) $at > self::ADMIN_WINDOW ) { unset( $seen[ $k ] ); } } arsort( $seen ); $out[ $key_id ] = array_slice( $seen, 0, self::ADMIN_MAX, true ); } update_option( self::OPTION_ADMIN_IPS, $out, false ); } /** * The admin IP hashes a key's state carries. * * @param string $key_id Key id. * @return string[] */ public static function admin_ip_hashes( string $key_id ): array { $all = get_option( self::OPTION_ADMIN_IPS, array() ); $seen = is_array( $all ) && is_array( $all[ $key_id ] ?? null ) ? $all[ $key_id ] : array(); $out = array(); foreach ( $seen as $h => $at ) { if ( self::now() - (int) $at <= self::ADMIN_WINDOW && 1 === preg_match( '/^[0-9a-f]{64}$/', (string) $h ) ) { $out[] = (string) $h; } } sort( $out ); return $out; } // ── Requests (section 3.1) ───────────────────────────────────────── /** * Handles one edge request. Pure apart from the options it reads and writes, so the tests * drive it directly; the REST glue below only feeds it and sends what it returns. * * @param string $route self::ROUTE_STATE or self::ROUTE_EVENTS. * @param string $method REQUEST_METHOD as received. * @param string $target REQUEST_URI as received, byte for byte. * @param array $headers Request headers, names lowercase with hyphens. * @param string $body Raw body. * @return array{status:int,headers:array,body:string} */ public static function handle( string $route, string $method, string $target, array $headers, string $body ): array { $now = self::now(); $h = static fn( string $n ): string => (string) ( $headers[ $n ] ?? '' ); $reg = self::registry(); if ( array() === $reg['keys'] ) { return self::refuse( 'not_paired' ); } $key_id = $h( 'x-csedge-key' ); $secret = isset( $reg['keys'][ $key_id ] ) ? self::secret( $key_id ) : ''; if ( '' === $secret ) { return self::refuse( 'unknown_key' ); } $nonce = $h( 'x-csedge-nonce' ); $err = CSDT_Edge_Sig::verify_request( $secret, $method, $target, $h( 'x-csedge-timestamp' ), $nonce, $h( 'x-csedge-signature' ), $body, $now ); if ( '' !== $err ) { return self::refuse( $err ); } $rt = self::runtime(); foreach ( $rt['nonces'] as $n => $at ) { if ( $now - (int) $at > self::NONCE_TTL ) { unset( $rt['nonces'][ $n ] ); } } if ( isset( $rt['nonces'][ $nonce ] ) ) { return self::refuse( 'replayed_nonce' ); } $rt['nonces'][ $nonce ] = $now; $cap = self::ROUTE_EVENTS === $route ? self::CAP_EVENTS : self::CAP_STATE; if ( strlen( $body ) > $cap ) { self::save_runtime( $rt ); return self::refuse( 'too_large', 413 ); } $minute = intdiv( $now, 60 ); $rate = is_array( $rt['rate'][ $key_id ] ?? null ) ? $rt['rate'][ $key_id ] : array( 0, 0 ); $rate = (int) $rate[0] === $minute ? array( $minute, (int) $rate[1] + 1 ) : array( $minute, 1 ); $rt['rate'][ $key_id ] = $rate; if ( $rate[1] > self::RATE_PER_MIN ) { self::save_runtime( $rt ); return self::refuse( 'rate_limited', 429, array( 'Retry-After' => (string) max( 1, ( $minute + 1 ) * 60 - $now ) ) ); } if ( self::ROUTE_STATE === $route ) { $rt['seen'][ $key_id ]['state'] = $now; self::save_runtime( $rt ); if ( 'GET' !== $method ) { return self::refuse( 'method', 405 ); } $st = self::state_body( $key_id ); if ( '' !== $h( 'if-none-match' ) && hash_equals( $st['etag'], $h( 'if-none-match' ) ) ) { return self::signed( $secret, $nonce, 304, null, array( 'ETag' => $st['etag'] ) ); } return self::signed( $secret, $nonce, 200, $st['data'], array( 'ETag' => $st['etag'] ) ); } if ( 'POST' !== $method ) { self::save_runtime( $rt ); return self::refuse( 'method', 405 ); } $batch = json_decode( $body, true ); if ( ! is_array( $batch ) || 1 !== (int) ( $batch['format'] ?? 0 ) || ( isset( $batch['events'] ) && ! is_array( $batch['events'] ) ) || count( (array) ( $batch['events'] ?? array() ) ) > self::MAX_EVENTS_IN ) { self::save_runtime( $rt ); return self::refuse( 'bad_body', 400 ); } $events = array_values( (array) ( $batch['events'] ?? array() ) ); $flood = is_array( $rt['flood'] ?? null ) ? $rt['flood'] : array( 0, 0 ); $flood = (int) $flood[0] === $minute ? $flood : array( $minute, 0 ); if ( array() !== $events && (int) $flood[1] + count( $events ) > self::FLOOD_PER_MIN ) { self::save_runtime( $rt ); return self::refuse( 'flooded', 429, array( 'Retry-After' => (string) max( 1, ( $minute + 1 ) * 60 - $now ) ) ); } $rt['flood'] = array( $minute, (int) $flood[1] + count( $events ) ); $rt['seen'][ $key_id ]['events'] = $now; $prune = $now - (int) ( $rt['pruned'] ?? 0 ) > HOUR_IN_SECONDS; if ( $prune ) { $rt['pruned'] = $now; } self::save_runtime( $rt ); $gw = is_array( $batch['gateway'] ?? null ) ? $batch['gateway'] : array(); self::record_health( $key_id, $gw, $now ); $rows = array(); $valid = 0; $gw_id = self::clip( (string) ( $gw['id'] ?? '' ), 64 ); foreach ( $events as $ev ) { $row = is_array( $ev ) ? self::event_row( $ev, $key_id, $gw_id, $now ) : null; if ( null === $row ) { continue; // No usable id or action: neither accepted nor a duplicate. } ++$valid; if ( ! isset( $rows[ $row['event_id'] ] ) ) { $rows[ $row['event_id'] ] = $row; } } $store = self::store(); $new = array(); if ( array() !== $rows ) { $store->install(); $new = $store->insert( array_values( $rows ) ); if ( null === $new ) { // Not accepted: answer unsigned, so the gateway keeps the events and retries. return self::refuse( 'store_failed', 503, array( 'Retry-After' => '30' ) ); } } if ( $prune ) { $store->prune( $now - self::EVENT_RETENTION, self::MAX_EVENT_ROWS ); } self::after_accept( array_values( array_intersect_key( $rows, array_flip( $new ) ) ) ); $accepted = count( $new ); $dups = $valid - $accepted; return self::signed( $secret, $nonce, 200, array( 'accepted' => $accepted, 'duplicates' => $dups, ), array() ); } /** * An unsigned refusal: {"error":""}. * * @param string $code Error code. * @param int $status HTTP status. * @param array $headers Extra headers. * @return array{status:int,headers:array,body:string} */ private static function refuse( string $code, int $status = 401, array $headers = array() ): array { $data = array( 'error' => $code ); return array( 'status' => $status, 'headers' => $headers + array( 'Cache-Control' => 'no-store' ), 'body' => self::encode( $data ), 'data' => $data, ); } /** * A signed answer (section 3.2), over exactly the bytes returned. * * @param string $secret Key. * @param string $nonce The request's nonce. * @param int $status HTTP status. * @param array|null $data Body as data, encoded once here; null for an empty body (304). * @param array $headers Extra headers. * @return array{status:int,headers:array,body:string,data:array|null} */ private static function signed( string $secret, string $nonce, int $status, ?array $data, array $headers ): array { $ts = self::now(); $body = null === $data ? '' : self::encode( $data ); return array( 'status' => $status, 'headers' => $headers + array( 'X-CSEdge-Timestamp' => (string) $ts, 'X-CSEdge-Signature' => 'v1=' . CSDT_Edge_Sig::response_signature( $secret, $nonce, $status, $ts, $body ), 'Cache-Control' => 'no-store', ), 'body' => $body, 'data' => $data, ); } /** The runtime option, normalised. */ private static function runtime(): array { $rt = get_option( self::OPTION_RUNTIME, array() ); $rt = is_array( $rt ) ? $rt : array(); foreach ( array( 'nonces', 'rate', 'seen' ) as $k ) { $rt[ $k ] = is_array( $rt[ $k ] ?? null ) ? $rt[ $k ] : array(); } return $rt; } /** Saves the runtime option. */ private static function save_runtime( array $rt ): void { update_option( self::OPTION_RUNTIME, $rt, false ); } /** * When each key last fetched state and posted events. * * @return array */ public static function last_seen(): array { return self::runtime()['seen']; } // ── Events (section 5) ───────────────────────────────────────────── /** * Trims and strips a string to a length, on a UTF-8 boundary. * * @param string $s Text. * @param int $max Bytes. * @return string */ private static function clip( string $s, int $max ): string { $s = (string) preg_replace( '/[\x00-\x1f\x7f]/', '', $s ); if ( strlen( $s ) <= $max ) { return $s; } if ( function_exists( 'mb_strcut' ) ) { return (string) mb_strcut( $s, 0, $max, 'UTF-8' ); } // Without mbstring: cut, then drop a trailing partial multibyte sequence. $s = substr( $s, 0, $max ); return (string) preg_replace( '/[\xc0-\xff][\x80-\xbf]*$/', '', $s ); } /** * One event as a row, or null when it has no usable id or action. * * @param array $ev Event. * @param string $key_id Key it arrived under. * @param string $gw_id Gateway id. * @param int $now Received at. * @return array|null */ public static function event_row( array $ev, string $key_id, string $gw_id, int $now ): ?array { $id = (string) ( $ev['id'] ?? '' ); $action = (string) ( $ev['action'] ?? '' ); if ( 1 !== preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $id ) || ! in_array( $action, array( 'block', 'log' ), true ) ) { return null; } $at = strtotime( (string) ( $ev['at'] ?? '' ) ); $req = is_array( $ev['request'] ?? null ) ? $ev['request'] : array(); $ip = CSDT_Edge_Sig::canonical_ip( (string) ( $req['ip'] ?? '' ) ); $cve = array(); foreach ( array_slice( (array) ( $ev['cve'] ?? array() ), 0, 10 ) as $c ) { if ( is_string( $c ) && 1 === preg_match( '/^CVE-\d{4}-\d{1,8}$/', $c ) ) { $cve[] = $c; } } $extra = array(); if ( is_array( $ev['shape'] ?? null ) ) { foreach ( array( 'body_bytes', 'parts', 'max_headers_per_part', 'max_name_depth' ) as $k ) { $extra['shape'][ $k ] = (int) ( $ev['shape'][ $k ] ?? 0 ); } } if ( is_array( $ev['rules'] ?? null ) ) { $extra['rules'] = array_values( array_map( static fn( $r ) => self::clip( (string) $r, 32 ), array_filter( array_slice( $ev['rules'], 0, 20 ), 'is_scalar' ) ) ); } $severity = strtolower( (string) ( $ev['severity'] ?? '' ) ); return array( 'event_id' => $id, 'received_at' => $now, 'at' => false === $at ? $now : (int) $at, 'gateway' => $gw_id, 'key_id' => $key_id, 'action' => $action, 'source' => self::clip( (string) preg_replace( '/[^a-z0-9_]/', '', strtolower( (string) ( $ev['source'] ?? '' ) ) ), 16 ), 'rule_id' => self::clip( (string) preg_replace( '/[^A-Za-z0-9._:-]/', '', (string) ( $ev['rule_id'] ?? '' ) ), 64 ), 'severity' => in_array( $severity, array( 'critical', 'high', 'medium', 'low', 'info' ), true ) ? $severity : 'info', 'cve' => implode( ',', $cve ), 'ip' => $ip, 'method' => self::clip( (string) preg_replace( '/[^A-Z]/', '', strtoupper( (string) ( $req['method'] ?? '' ) ) ), 10 ), 'host' => self::clip( (string) ( $req['host'] ?? '' ), 255 ), 'path' => self::clip( (string) ( $req['path'] ?? '' ), 512 ), 'ua' => self::clip( (string) ( $req['ua'] ?? '' ), 256 ), 'content_type' => self::clip( (string) ( $req['content_type'] ?? '' ), 128 ), 'extra' => self::encode( (object) $extra ), ); } /** * Records the health block a batch carried, by gateway id. * * @param string $key_id Key. * @param array $gw Health block. * @param int $now Time. * @return void */ private static function record_health( string $key_id, array $gw, int $now ): void { $all = get_option( self::OPTION_HEALTH, array() ); $all = is_array( $all ) ? $all : array(); $id = self::clip( (string) ( $gw['id'] ?? '' ), 64 ); $id = '' !== $id ? $id : $key_id; $feeds = array(); foreach ( array_slice( (array) ( $gw['feeds'] ?? array() ), 0, 20, true ) as $name => $serial ) { $feeds[ self::clip( (string) $name, 40 ) ] = (int) $serial; } $all[ $id ] = array( 'key_id' => $key_id, 'version' => self::clip( (string) ( $gw['version'] ?? '' ), 40 ), 'engine' => self::clip( (string) ( $gw['engine'] ?? '' ), 60 ), 'crs' => self::clip( (string) ( $gw['crs'] ?? '' ), 40 ), 'feeds' => $feeds, 'started_at' => self::clip( (string) ( $gw['started_at'] ?? '' ), 40 ), 'dropped_events' => (int) ( $gw['dropped_events'] ?? 0 ), 'engine_errors' => (int) ( $gw['engine_errors'] ?? 0 ), 'last_seen' => $now, ); foreach ( $all as $k => $v ) { if ( $now - (int) ( $v['last_seen'] ?? 0 ) > self::HEALTH_KEEP ) { unset( $all[ $k ] ); } } uasort( $all, static fn( $a, $b ) => (int) $b['last_seen'] <=> (int) $a['last_seen'] ); update_option( self::OPTION_HEALTH, array_slice( $all, 0, self::HEALTH_MAX, true ), false ); } /** * The gateways' last health blocks, newest first. * * @return array> */ public static function health(): array { $all = get_option( self::OPTION_HEALTH, array() ); return is_array( $all ) ? $all : array(); } /** When any gateway was last heard from (events or a state poll), 0 for never. */ public static function last_heartbeat(): int { $last = 0; foreach ( self::health() as $h ) { $last = max( $last, (int) ( $h['last_seen'] ?? 0 ) ); } $keys = self::registry()['keys']; foreach ( self::last_seen() as $key_id => $seen ) { if ( isset( $keys[ $key_id ] ) && is_array( $seen ) ) { $last = max( $last, (int) ( $seen['state'] ?? 0 ), (int) ( $seen['events'] ?? 0 ) ); } } return $last; } /** * What follows an accepted batch: one activity-log line for its critical events and one * security alert per critical rule an hour. Never one row per event: the activity log is * capped, and a flood of edge events would evict every other security record. * * @param array> $rows New rows. * @return void */ private static function after_accept( array $rows ): void { $crit = array_values( array_filter( $rows, static fn( $r ) => 'critical' === $r['severity'] ) ); if ( array() === $crit ) { return; } $blocked = count( array_filter( $crit, static fn( $r ) => 'block' === $r['action'] ) ); $rules = array_count_values( array_map( static fn( $r ) => (string) $r['rule_id'], $crit ) ); arsort( $rules ); $top = (string) array_key_first( $rules ); self::activity( 'edge_gateway_critical', $top, sprintf( '%d critical request(s): %d blocked, %d logged only. First from %s to %s', count( $crit ), $blocked, count( $crit ) - $blocked, $crit[0]['ip'], $crit[0]['path'] ) ); if ( ! class_exists( 'CSDT_Alert_Events' ) ) { return; } $now = self::now(); $sent = get_option( self::OPTION_ALERTED, array() ); $sent = is_array( $sent ) ? $sent : array(); foreach ( $sent as $k => $at ) { if ( $now - (int) $at > DAY_IN_SECONDS ) { unset( $sent[ $k ] ); } } $changed = false; foreach ( $crit as $r ) { $rule = (string) $r['rule_id']; if ( $now - (int) ( $sent[ $rule ] ?? 0 ) < HOUR_IN_SECONDS ) { continue; } $sent[ $rule ] = $now; $changed = true; $verb = 'block' === $r['action'] ? 'blocked' : 'would have blocked (Log mode)'; $text = 'CSDT: Edge Gateway ' . $verb . ' a critical request' . "\n" . 'Rule ' . $rule . ( '' !== $r['cve'] ? ' (' . $r['cve'] . ')' : '' ) . ', ' . $r['method'] . ' ' . $r['path'] . ' from ' . $r['ip'] . '.' . ( 'log' === $r['action'] ? ' The gateway is in Log mode for ' . $r['source'] . ', so this request reached the site.' : '' ); // request_is_subject = false: the request in flight is the gateway POSTing this event // batch to our own events route, not the attacker. The attacker's IP, method and path // are in $text above; the origin block must not name the courier instead. CSDT_Alert_Events::send( CSDT_Alert_Events::SECURITY, $text, 'Edge Gateway', 'critical', false ); } if ( $changed ) { update_option( self::OPTION_ALERTED, $sent, false ); } } /** * Writes one activity-log line, when the log exists. * * @param string $event Event. * @param string $object Object. * @param string $detail Detail. * @return void */ private static function activity( string $event, string $object, string $detail ): void { if ( class_exists( 'CSDT_Activity_Log' ) ) { CSDT_Activity_Log::record( $event, $object, $detail ); } } // ── Forward header and bypass (section 3.3) ──────────────────────── /** * This request's forward header: 'unpaired', 'missing', 'bad' or 'ok'. One HMAC at most, * remembered for the request. * * @return string */ public static function forward_status(): string { if ( null !== self::$forward ) { return self::$forward; } $keys = self::registry_front()['keys']; if ( array() === $keys ) { self::$forward = 'unpaired'; return self::$forward; } $sl = self::slashed(); $header = CSDT_Request::server( 'HTTP_X_CLOUDSCALE_EDGE', 256, $sl ); if ( '' === $header ) { self::$forward = 'missing'; return self::$forward; } $key_id = CSDT_Edge_Sig::forward_key_id( $header ); $secret = isset( $keys[ $key_id ] ) ? self::secret( $key_id ) : ''; // Verified byte for byte by the HMAC, so read verbatim (bounded), never sanitised. $method = CSDT_Request::server( 'REQUEST_METHOD', 16, $sl ); $host = CSDT_Request::server( 'HTTP_HOST', 255, $sl ); $target = CSDT_Request::server( 'REQUEST_URI', 8192, $sl ); $ok = '' !== $secret && '' === CSDT_Edge_Sig::verify_forward( $secret, $key_id, $header, $method, $host, $target, self::now() ); self::$forward = $ok ? 'ok' : 'bad'; return self::$forward; } /** * Whether wp_magic_quotes() has slashed $_SERVER yet. WordPress runs it straight after * plugins_loaded, so the sampler (plugins_loaded, priority 0) and an IP lookup made during * plugins_loaded see PHP's own bytes, and everything later sees the slashed copy. * * @return bool */ private static function slashed(): bool { if ( ! function_exists( 'did_action' ) || ! function_exists( 'doing_action' ) ) { return true; } return did_action( 'plugins_loaded' ) > 0 && ! doing_action( 'plugins_loaded' ); } /** Whether this request carries a genuine, current forward header. */ public static function forward_verified(): bool { return 'ok' === self::forward_status(); } /** * Hooked to plugins_loaded at priority 0, only while a gateway is paired: samples one * request in N into hourly counters. No write on the other N-1, and none at all unpaired. * * @return void */ public static function maybe_sample_bypass(): void { try { $reg = self::registry_front(); if ( array() === $reg['keys'] || self::skip_for_bypass() ) { return; } $take = null !== self::$sample_override ? (bool) call_user_func( self::$sample_override ) : 1 === wp_rand( 1, $reg['sample'] ); if ( ! $take ) { return; } $status = self::forward_status(); $hour = intdiv( self::now(), HOUR_IN_SECONDS ) * HOUR_IN_SECONDS; $b = get_option( self::OPTION_BYPASS, array() ); $b = is_array( $b ) ? $b : array(); $row = is_array( $b[ $hour ] ?? null ) ? $b[ $hour ] : array( 0, 0, 0 ); $i = array( 'ok' => 0, 'missing' => 1, 'bad' => 2, )[ $status ] ?? 2; $row[ $i ] = (int) $row[ $i ] + 1; $b[ $hour ] = $row; krsort( $b ); update_option( self::OPTION_BYPASS, array_slice( $b, 0, self::BYPASS_HOURS, true ), false ); } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } } } /** * Requests that legitimately reach the origin without the gateway: WP-CLI, cron, the * gateway's own calls to the edge routes, and WordPress's loopback requests to itself. * * @return bool */ private static function skip_for_bypass(): bool { // The tests run under the CLI and set a sampler; production CLI and cron never sample. if ( null === self::$sample_override && ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || 'cli' === PHP_SAPI ) ) { return true; } $uri = CSDT_Request::server( 'REQUEST_URI', 8192, self::slashed() ); $ua = CSDT_Request::server( 'HTTP_USER_AGENT', 512, self::slashed() ); return false !== stripos( rawurldecode( $uri ), 'csdt/v1/edge/' ) || 0 === strpos( $ua, 'WordPress/' ); } /** * Sampled forward-header results since a time. * * @param int $since Unix time. * @return array{ok:int,missing:int,bad:int,total:int,pct:float|null} */ public static function bypass_stats( int $since ): array { $b = get_option( self::OPTION_BYPASS, array() ); $out = array( 'ok' => 0, 'missing' => 0, 'bad' => 0, ); foreach ( is_array( $b ) ? $b : array() as $hour => $row ) { if ( (int) $hour + HOUR_IN_SECONDS <= $since || ! is_array( $row ) ) { continue; } $out['ok'] += (int) ( $row[0] ?? 0 ); $out['missing'] += (int) ( $row[1] ?? 0 ); $out['bad'] += (int) ( $row[2] ?? 0 ); } $out['total'] = $out['ok'] + $out['missing'] + $out['bad']; $out['pct'] = $out['total'] > 0 ? round( 100 * ( $out['missing'] + $out['bad'] ) / $out['total'], 1 ) : null; return $out; } // ── Upload-path learning ─────────────────────────────────────────── /** * Paths the gateway saw multipart requests on, outside every upload path, in the last 7 * days of log-mode shape events, minus the ones already allowed. * * @return array */ public static function upload_suggestions(): array { $have = array(); foreach ( self::upload_paths() as $p ) { $have[] = $p; } $out = array(); foreach ( self::store()->upload_candidates( self::now() - self::LEARN_WINDOW, 50 ) as $c ) { $path = rawurldecode( (string) $c['path'] ); if ( '' === $path || '/' !== $path[0] ) { continue; } $covered = false; foreach ( $have as $p ) { if ( $path === $p['path'] || ( 'prefix' === $p['match'] && 0 === strpos( $path, $p['path'] ) ) ) { $covered = true; break; } } if ( ! $covered ) { $c['path'] = $path; $out[] = $c; } } return $out; } // ── Site Audit (plan section 5.4) ────────────────────────────────── /** * Findings, from stored data only. None at all while no gateway is paired. * * @return array> */ public static function audit_findings(): array { $reg = self::registry(); if ( array() === $reg['keys'] ) { return array(); } $now = self::now(); $out = array(); $by = self::bypass_stats( $now - DAY_IN_SECONDS ); if ( $by['total'] >= self::BYPASS_MIN && null !== $by['pct'] && $by['pct'] >= self::BYPASS_WARN_PCT ) { $out[] = array( 'key' => 'edge_gateway_bypass', 'category' => 'Security', 'severity' => $by['pct'] >= 50 ? 'critical' : 'high', /* translators: %s: percentage */ 'title' => sprintf( 'Gateway paired but %s%% of requests bypass it', (string) $by['pct'] ), 'detail' => sprintf( 'In the last 24 hours %d of %d sampled requests reached WordPress without a valid gateway signature. The origin is reachable directly, so an attacker can go around every rule the gateway enforces.', $by['missing'] + $by['bad'], $by['total'] ), 'fix' => 'Firewall the web server so only the gateway can reach it (security group, nginx allow/deny, or bind to a private address), then watch the bypass figure in Request Firewall → Edge Gateway fall to zero.', 'affected' => 'Edge Gateway', ); } $created = 0; foreach ( $reg['keys'] as $k ) { $created = max( $created, (int) $k['created'] ); } $last = self::last_heartbeat(); if ( $now - max( $last, $created ) > self::SILENT_AFTER ) { $out[] = array( 'key' => 'edge_gateway_silent', 'category' => 'Security', 'severity' => 'high', 'title' => 0 === $last ? 'Paired gateway has never reported in' : 'Gateway has not reported for over 10 minutes', 'detail' => 0 === $last ? 'A gateway was paired but has never sent a heartbeat. Either it is not installed yet or it cannot reach this site.' : sprintf( 'The last heartbeat was %d minutes ago. A gateway sends one at least every minute, so it is stopped, cannot reach this site, or its pairing token no longer matches.', (int) floor( ( $now - $last ) / 60 ) ), 'fix' => 'Check the gateway is running and its logs, and that it can reach this site directly. If it was replaced, unpair the old key in Request Firewall → Edge Gateway.', 'affected' => 'Edge Gateway', ); } $modes = self::settings()['modes']; $logs = array_keys( array_filter( $modes, static fn( $m ) => 'log' === $m ) ); if ( array() !== $logs ) { $c = self::store()->counts( $now - 7 * DAY_IN_SECONDS ); if ( $c['critical_log'] > 0 ) { $out[] = array( 'key' => 'edge_gateway_log_criticals', 'category' => 'Security', 'severity' => 'high', /* translators: %d: count */ 'title' => sprintf( 'Gateway is in Log mode with %d critical would-block(s) in the last 7 days', $c['critical_log'] ), 'detail' => 'The gateway matched requests it rates critical and let them through, because the feature that matched is in Log mode (' . implode( ', ', $logs ) . '). Those requests reached the site.', 'fix' => 'Read the recent events in Request Firewall → Edge Gateway. If they are attacks and not your own traffic, set that feature to Block.', 'affected' => 'Edge Gateway', ); } } return $out; } // ── WordPress glue ───────────────────────────────────────────────── /** Registers the routes, the raw sender, the login hook and, while paired, the sampler. */ public static function register(): void { add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); add_filter( 'rest_pre_serve_request', array( __CLASS__, 'serve_raw' ), 10, 4 ); add_action( 'wp_login', array( __CLASS__, 'on_login' ), 10, 2 ); if ( array() !== self::registry_front()['keys'] ) { add_action( 'plugins_loaded', array( __CLASS__, 'maybe_sample_bypass' ), 0 ); } } /** Registers both edge routes. Authentication is the HMAC, checked in handle(). */ public static function register_routes(): void { foreach ( array( self::ROUTE_STATE => 'rest_state', self::ROUTE_EVENTS => 'rest_events' ) as $route => $cb ) { register_rest_route( self::REST_NS, $route, array( 'methods' => 'GET, POST, PUT, PATCH, DELETE', 'callback' => array( __CLASS__, $cb ), // Signed requests only: the HMAC in handle() is the authentication, and the // gateway refuses these paths to every client (contract section 6). 'permission_callback' => '__return_true', ) ); } } /** * GET /csdt/v1/edge/state. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function rest_state( $request ) { return self::rest( self::ROUTE_STATE, $request ); } /** * POST /csdt/v1/edge/events. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public static function rest_events( $request ) { return self::rest( self::ROUTE_EVENTS, $request ); } /** * Feeds handle() from the raw request and parks the body for serve_raw(). * * @param string $route Route. * @param WP_REST_Request $request Request. * @return WP_REST_Response */ private static function rest( string $route, $request ) { try { // A minted test session has admin rights without a password or second factor; the // same rule as CSDT_Ajax's refuse_test_session. Only a logged-in viewer can be one. if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() && is_callable( array( 'CSDT_DevTools', 'viewer_is_test_session' ) ) && CSDT_DevTools::viewer_is_test_session() ) { $out = self::refuse( 'test_session', 403 ); } else { $headers = array(); foreach ( array( 'x-csedge-key', 'x-csedge-timestamp', 'x-csedge-nonce', 'x-csedge-signature', 'if-none-match' ) as $n ) { $headers[ $n ] = (string) $request->get_header( str_replace( '-', '_', $n ) ); } // The raw bytes are what is signed: read verbatim (bounded), never sanitised. $method = CSDT_Request::server( 'REQUEST_METHOD', 16, self::slashed() ); $target = CSDT_Request::server( 'REQUEST_URI', 8192, self::slashed() ); $out = self::handle( $route, $method, $target, $headers, (string) $request->get_body() ); } } catch ( \Throwable $e ) { if ( class_exists( 'CSDT_Monitor' ) ) { CSDT_Monitor::log_exception( $e, __METHOD__ ); } $out = self::refuse( 'internal', 500 ); } self::$raw = array( $out['data'] ); $resp = new WP_REST_Response( null, $out['status'] ); foreach ( $out['headers'] as $k => $v ) { $resp->header( $k, $v ); } $resp->header( 'Content-Type', 'application/json' ); return $resp; } /** * rest_pre_serve_request: sends an edge route's body exactly as signed. WordPress would * pass the response through its own envelope, field filters and encoding options, and any * different byte fails the signature, so the same wp_json_encode() call that produced the * signed bytes in handle() produces the sent ones here. * * @param mixed $served Already served. * @param mixed $result Response. * @param mixed $request Request. * @param mixed $_server Server, unused. * @return mixed */ public static function serve_raw( $served, $result = null, $request = null, $_server = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- filter arity if ( $served || null === self::$raw || ! is_object( $request ) || ! method_exists( $request, 'get_route' ) || 0 !== strpos( (string) $request->get_route(), '/' . self::REST_NS . '/edge/' ) ) { return $served; } $data = self::$raw[0]; self::$raw = null; if ( null !== $data ) { echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES ); } return true; } }