binding_json); } } function request_fingerprint($request) { return hash('sha256', serialize([$request->get_method(), $request->get_route(), $_SERVER['REQUEST_URI'] ?? '', $_SERVER['REQUEST_METHOD'] ?? $request->get_method(), is_ssl(), $request->get_headers(), ($request->get_body() ?? ''), $request->get_query_params()])); } function connector_route($request) { return $request->get_route() === '/publisher/v1' || str_starts_with($request->get_route(), '/publisher/v1/'); } /** Signatures and setup proofs are request-bound, including their error responses. */ function prevent_response_caching($response, $server, $request) { if (!connector_route($request) && !preg_match('~^/aicpub/v1/setup-proof/[0-9a-f-]{36}/[0-9a-f]{32}$~D', $request->get_route())) return $response; // LSCache's REST cache decision can override the ordinary Cache-Control header. // This optional action has no effect when LiteSpeed Cache is not installed. do_action('litespeed_control_set_nocache', 'AICPub request-bound REST response'); $response->header('Cache-Control', 'no-store'); return $response; } /** Connector responses have no _fields projection; avoid WP lazily decoding rejected bodies. */ function filter_response_fields($response, $server, $request) { return connector_route($request) ? $response : rest_filter_response_fields($response, $server, $request); } function filter_oembed_response($served, $response, $request, $server) { return connector_route($request) ? $served : _oembed_rest_pre_serve_request($served, $response, $request, $server); } /** Runs before WP_REST_Request::has_valid_params() can decode JSON. */ function pre_dispatch_authentication($result, $server, $request) { if (!connector_route($request)) return $result; $permission = authenticate($request); if (is_wp_error($permission)) return $permission; try { strict_request_body($request); // Authenticated malformed/schema-invalid JSON never reaches WP's permissive decoder. return $result; } catch (Failure $e) { return signed_response($request, obj(['code' => $e->getMessage(), 'write_attempted' => false]), $e->status); } catch (\Throwable $e) { return signed_response($request, obj(['code' => 'connector_unavailable']), 503); } } function strict_request_body($request) { $ctx = request_context($request); demand($ctx instanceof AuthenticatedRequest && hash_equals($ctx->fingerprint, request_fingerprint($request)), 'authentication_failed', 401); if ($request->get_method() !== 'POST') return null; $route = $request->get_route(); $body = parse_json($ctx->raw_body); $retirement = preg_match('~^/publisher/v1/operations/[0-9a-f]{64}/retire$~D', $route); $def = preg_match('~^/publisher/v1/operations/[0-9a-f]{64}/control$~D',$route) ? 'historicalControlRequest' : ($route === '/publisher/v1/pairing/confirm' ? 'pairingConfirm' : ($route === '/publisher/v1/media' || ($retirement && ($body->operation ?? null) === 'media.upload') ? 'mediaRequest' : 'publicationRequest')); return schema($def, $body); } function request_context($request, $context = null) { static $contexts; if (!$contexts) $contexts = new \WeakMap(); if ($context !== null) $contexts[$request] = $context; return $contexts[$request] ?? null; } function authenticate($request) { try { $raw = $request->get_body() ?? ''; demand(strlen($raw) <= 12582912, 'body_limit', 413); $headers = $request->get_headers(); demand(count($headers) <= 64, 'authentication_failed', 401); $size = 0; $allowed = ['x_publisher_tenant', 'x_publisher_site', 'x_publisher_installation', 'x_publisher_connection', 'x_publisher_key', 'x_publisher_time', 'x_publisher_nonce', 'x_publisher_body_sha256', 'x_publisher_idempotency', 'x_publisher_signature']; foreach ($headers as $name => $values) { foreach ($values as $v) { $size += strlen($name) + strlen($v) + 4; demand(preg_match('/^[\x20-\x7e]*$/D', $v), 'authentication_failed', 401); } demand($name !== 'content_encoding' && $name !== 'x_http_method_override', 'authentication_failed', 401); if (str_starts_with($name, 'x_publisher_')) demand(in_array($name, $allowed, true), 'authentication_failed', 401); if (in_array($name, $allowed, true) || $name === 'content_type') demand(count($values) === 1, 'authentication_failed', 401); } demand($size <= 16384, 'authentication_failed', 401); foreach ($allowed as $name) demand(isset($headers[$name]) && count($headers[$name]) === 1, 'authentication_failed', 401); $fingerprint = request_fingerprint($request); $cached = request_context($request); if ($cached !== null) { if ($cached instanceof AuthenticatedRequest && hash_equals($cached->fingerprint, $fingerprint)) return true; request_context($request, false); // A mutated request object cannot recover its old authorization. throw new Failure('authentication_failed', 401); } $b = current_binding(); $secret = secret_open($b); $method = $request->get_method(); $route = $request->get_route(); $valid = $method === 'GET' ? in_array($route, ['/publisher/v1/pairing', '/publisher/v1/capabilities', '/publisher/v1/site-options'], true) || preg_match('~^/publisher/v1/operations/[0-9a-f]{64}$~D', $route) || preg_match('~^/publisher/v1/publications/by-url/[A-Za-z0-9_-]{1,2731}$~D', $route) : $method === 'POST' && in_array($route, ['/publisher/v1/pairing/confirm', '/publisher/v1/media', '/publisher/v1/publication-intents'], true); $valid=$valid || ($method==='POST' && preg_match('~^/publisher/v1/operations/[0-9a-f]{64}/(?:retire|control)$~D',$route)); demand($valid && ($_SERVER['REQUEST_METHOD'] ?? $method) === $method && is_ssl() && !$request->get_query_params(), 'authentication_failed', 401); demand(($_SERVER['REQUEST_URI'] ?? '') === wp_parse_url($b->api_base, PHP_URL_PATH) . ltrim($route, '/'), 'authentication_failed', 401); foreach (['tenant', 'site', 'installation', 'connection', 'key'] as $id) demand($request->get_header('x-publisher-' . $id) === $b->{$id . '_id'}, 'authentication_failed', 401); $key = $request->get_header('x-publisher-idempotency'); $type = $request->get_header('content-type') ?? ''; demand($method === 'GET' ? $raw === '' && $type === '' && $key === '-' : $type === 'application/json' && preg_match('/^[0-9a-f]{64}$/D', $key), 'authentication_failed', 401); $time = $request->get_header('x-publisher-time'); $nonce = $request->get_header('x-publisher-nonce'); demand(preg_match('/^(0|[1-9][0-9]{0,15})$/D', $time) && (int) $time <= 9007199254740331 && abs(time() - (int) $time) <= 300 && preg_match('/^[0-9a-f]{32}$/D', $nonce), 'authentication_failed', 401); $hash = hash('sha256', $raw); $mac = hash_hmac('sha256', signing_input($b, $method, $route, $time, $nonce, $method === 'GET' ? '-' : $type, $hash, $key), $secret); demand(hash_equals($hash, $request->get_header('x-publisher-body-sha256')) && hash_equals($mac, $request->get_header('x-publisher-signature')), 'authentication_failed', 401); $scope = hash('sha256', $b->site_id . '/' . $b->connection_id . '/' . $b->key_id); // Valid MACs are rate-limited before durable nonce allocation so an // exhausted caller cannot grow the nonce journal without bound. consume_rate_limit(journal_scope($b), 'authenticated', 120, 60); demand(journal_insert($scope, 'nonce:' . $nonce, obj(['retain_until' => time() + 660])), 'nonce_replay', 409); request_context($request, new AuthenticatedRequest($fingerprint, canonical($b), $secret, $nonce, $raw)); return true; } catch (Failure $e) { if (request_context($request) instanceof AuthenticatedRequest) request_context($request, false); return new \WP_Error($e->getMessage(), 'Connector request denied.', ['status' => $e->status]); } catch (\Throwable $e) { return new \WP_Error('connector_unavailable', 'Connector request denied.', ['status' => 503]); } } function signed_response($request, $value, $status) { $ctx = request_context($request); demand($ctx instanceof AuthenticatedRequest, 'authentication_failed', 401); $b = $ctx->binding(); $secret = $ctx->secret; $nonce = $ctx->nonce; $raw = canonical($value); $hash = hash('sha256', $raw); $input = implode("\n", ['publisher-response-v1', $nonce, (string) $status, $b->tenant_id, $b->site_id, $b->connection_id, $b->key_id, $hash]); $response = new SignedResponse($value, $status); $response->publisher_raw = $raw; $response->header('X-Publisher-Response-SHA256', $hash); $response->header('X-Publisher-Response-Signature', hash_hmac('sha256', $input, $secret)); $response->header('Cache-Control', 'no-store'); return $response; } function dispatch($request) { try { $permission = authenticate($request); if (is_wp_error($permission)) return $permission; $ctx = request_context($request); $b = current_binding($ctx->binding()); $route = $request->get_route(); $method = $request->get_method(); if ($method === 'GET' && $route === '/publisher/v1/pairing') return signed_response($request, pairing_status($b), 200); if ($method === 'POST') { $body = strict_request_body($request); foreach (['tenant_id', 'site_id', 'installation_id', 'connection_id', 'policy_hash'] as $key) demand($body->{$key} === $b->{$key}, 'binding_mismatch', 403); demand($body->idempotency_key === $request->get_header('x-publisher-idempotency'), 'idempotency_mismatch', 409); if ($route === '/publisher/v1/pairing/confirm') return signed_response($request, confirm_pairing($b, $body), 200); } demand($b->state === 'active', 'active_required', 403); if ($method==='POST' && preg_match('~^/publisher/v1/operations/([0-9a-f]{64})/control$~D',$route,$match)) { demand($match[1]===$body->idempotency_key,'idempotency_mismatch',409); return signed_response($request,control_historical_operation($b,$body),200); } if ($method==='POST' && preg_match('~^/publisher/v1/operations/([0-9a-f]{64})/retire$~D',$route,$match)) { demand($match[1]===$body->idempotency_key,'idempotency_mismatch',409); return signed_response($request,retire_operation($b,$body),200); } if ($method === 'GET') { $policy = effective_policy($b); if ($route === '/publisher/v1/site-options') { demand(in_array('site.read', $policy->capabilities, true), 'grant_denied', 403); $result = pick($b, ['protocol_version','tenant_id','site_id','installation_id','connection_id','policy_hash']); $result->authors = []; $result->categories = []; foreach ($policy->allowed_author_ids as $id) { $u = get_userdata($id); if ($u) $result->authors[] = obj(['id' => $id, 'name' => mb_substr($u->display_name,0,512)]); } foreach ($policy->allowed_category_ids as $id) { $term = get_term($id,'category'); if ($term && !is_wp_error($term)) $result->categories[] = obj(['id' => $id, 'name' => mb_substr($term->name,0,512)]); } schema('siteOptions',$result);return signed_response($request,$result,200); } if ($route === '/publisher/v1/capabilities') { demand(in_array('site.read', $policy->capabilities, true), 'grant_denied', 403); $result = pick($b, ['protocol_version', 'tenant_id', 'site_id', 'installation_id', 'connection_id', 'policy_hash']); $result->policy = $policy; $result->limits = obj(['wire_bytes' => 12582912, 'media_bytes' => 8388608, 'image_pixels' => 24000000, 'image_dimension' => 8192, 'clock_skew_seconds' => 300, 'nonce_retention_seconds' => 660, 'approval_max_seconds' => 900]); schema('capabilities', $result); return signed_response($request, $result, 200); } if (str_starts_with($route, '/publisher/v1/publications/by-url/')) { demand(in_array('site.read', $policy->capabilities, true), 'grant_denied', 403); $result = read_publication_by_url($b, $request->get_param('url_token')); schema('publicationLookup', $result); return signed_response($request, $result, 200); } demand(in_array('operation.read', $policy->capabilities, true), 'grant_denied', 403); $key = basename($route); $row = journal_read(journal_scope($b), 'operation:' . $key); demand($row !== null, 'operation_absent', 404); demand($row->state!=='cancelled','operation_retired',409); return signed_response($request, operation_status($b, $key, $row), $row->state === 'completed' ? 200 : 202); } [$result, $status] = execute_effect($b, $body); return signed_response($request, $result, $status); } catch (Failure $e) { return signed_response($request, obj(['code' => $e->getMessage(), 'write_attempted' => false]), $e->status); } catch (\Throwable $e) { return signed_response($request, obj(['code' => 'outcome_unknown']), 503); } } function serve_signed($served, $result, $request, $server) { if (!connector_route($request)) return $served; // The canonical JSON bytes are authenticated by the response MAC; HTML escaping would invalidate the signed protocol response. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Signed canonical JSON, served by the REST JSON endpoint. if ($result instanceof SignedResponse) echo $result->publisher_raw; else echo wp_json_encode($result->get_data(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // Serve unsigned rejections here too: WP's later _pretty lookup would otherwise // lazily JSON-decode the rejected raw request. Authentication errors stay unsigned. return true; } function register_routes() { register_rest_route('aicpub/v1', '/setup-proof/(?P[0-9a-f-]{36})/(?P[0-9a-f]{32})', ['methods' => 'GET', 'callback' => __NAMESPACE__ . '\\browser_setup_installation_proof', 'permission_callback' => '__return_true']); // Preserve the core field-filter behavior everywhere except this closed protocol. remove_filter('rest_pre_serve_request', '_oembed_rest_pre_serve_request', 10); add_filter('rest_pre_serve_request', __NAMESPACE__ . '\\filter_oembed_response', 10, 4); remove_filter('rest_post_dispatch', 'rest_filter_response_fields', 10); add_filter('rest_post_dispatch', __NAMESPACE__ . '\\filter_response_fields', 10, 3); foreach ([['GET', '/pairing'], ['POST', '/pairing/confirm'], ['GET', '/capabilities'], ['GET', '/site-options'], ['POST', '/media'], ['POST', '/publication-intents'], ['GET', '/operations/(?P[0-9a-f]{64})'], ['POST','/operations/(?P[0-9a-f]{64})/retire'], ['POST','/operations/(?P[0-9a-f]{64})/control'], ['GET', '/publications/by-url/(?P[A-Za-z0-9_-]{1,2731})']] as [$method, $route]) { register_rest_route('publisher/v1', $route, ['methods' => $method, 'callback' => __NAMESPACE__ . '\\dispatch', 'permission_callback' => __NAMESPACE__ . '\\authenticate']); } }