\WP_REST_Server::READABLE, 'callback' => array( $this, 'token' ), // Public by design: the Ask panel is for visitors, and a // visitor is usually not signed in. The token it returns can // only see what this visitor's roles may see. 'permission_callback' => '__return_true', ) ); } /** * The visitor's identity, or a plain refusal. * * @return \WP_REST_Response */ public function token(): \WP_REST_Response { if ( ! $this->connection->is_connected() || ! (bool) $this->connection->get( 'ask_enabled', true ) ) { return self::uncacheable( new \WP_REST_Response( array( 'error' => 'ask_unavailable' ), 404 ) ); } if ( ! $this->recognised() && ! Throttle::allow( 'token', self::limit(), self::WINDOW ) ) { // Deliberately the same body as every other refusal. A caller // being throttled is told to come back later by the status and // the header; it is not told how the limit works. $response = new \WP_REST_Response( array( 'error' => 'ask_unavailable' ), 429 ); $response->header( 'Retry-After', (string) self::WINDOW ); return self::uncacheable( $response ); } $space = ( new VisitorToken( $this->connection ) )->space(); if ( ! $space['ok'] ) { // The reason is for the log, not for the page: a visitor can do // nothing with it, and it names the workspace's internals. return self::uncacheable( new \WP_REST_Response( array( 'error' => 'ask_unavailable' ), 503 ) ); } return self::uncacheable( new \WP_REST_Response( array( 'workspaceId' => $this->connection->workspace_id(), 'baseUrl' => $this->connection->api_base(), 'token' => $space['token'], 'spaceId' => $space['space_id'], ), 200 ) ); } /** * Whether we have met this caller before. * * A signed-in visitor is known by their user id, which needs no cookie and * cannot be multiplied by discarding one. Everybody else is known by the * cookie `VisitorToken` sets, and the shape is checked rather than the * mere presence: a cookie of the wrong shape is ignored there too, so a * caller could otherwise send one byte of junk and look familiar. */ private function recognised(): bool { if ( is_user_logged_in() ) { return true; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- sanitized here and matched against a fixed charset. $cookie = isset( $_COOKIE[ VisitorToken::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ VisitorToken::COOKIE ] ) ) : ''; return 1 === preg_match( '/^[a-f0-9]{32}$/', $cookie ); } /** * The ceiling, after the site has had its say. */ private static function limit(): int { /** * Filters how many new visitor spaces one address may create per hour. * * Zero turns the limit off, which is the right setting only where * something in front of WordPress is already doing this job. * * @param int $limit The default ceiling. */ return (int) apply_filters( 'sharelyai_token_rate_limit', self::NEW_VISITORS ); } /** * Says "do not keep this" to every layer that might. * * A CDN or a page cache that held one visitor's token and served it to the * next would hand them somebody else's roles. * * @param \WP_REST_Response $response The response. */ private static function uncacheable( \WP_REST_Response $response ): \WP_REST_Response { $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, private' ); $response->header( 'Pragma', 'no-cache' ); $response->header( 'Expires', '0' ); nocache_headers(); return $response; } }