/ and sends the controller's response. */ class Endpoint { const QUERY_VAR = 'feedweaver_feed'; const KEY_PARAM = 'access_key'; const REWRITE_VERSION = '1'; /** Longest credential accepted for parsing: a valid header is 51 bytes ("Bearer " plus 43). */ const MAX_CREDENTIAL_BYTES = 1024; const VERSION_OPTION = 'feedweaver_rewrite_version'; /** @var Feed_Controller */ private $controller; /** * @param Feed_Controller $controller Controller. */ public function __construct( Feed_Controller $controller ) { $this->controller = $controller; } /** * Hooks into WordPress. */ public function register(): void { add_filter( 'query_vars', array( $this, 'add_query_var' ) ); add_action( 'template_redirect', array( $this, 'maybe_serve' ), 0 ); // Flush once after install/upgrade, never on ordinary requests. add_action( 'init', array( $this, 'maybe_flush' ), 99 ); } /** * Adds the rewrite rule. Called on init and from activation. */ public static function add_rewrite_rule(): void { add_rewrite_rule( '^feedweaver/([a-z0-9]+(?:-[a-z0-9]+)*)/?$', 'index.php?' . self::QUERY_VAR . '=$matches[1]', 'top' ); } /** * @param string[] $vars Query vars. * @return string[] */ public function add_query_var( array $vars ): array { $vars[] = self::QUERY_VAR; return $vars; } /** * Flushes rewrite rules once per rewrite version. */ public function maybe_flush(): void { if ( get_option( self::VERSION_OPTION ) !== self::REWRITE_VERSION ) { flush_rewrite_rules( false ); update_option( self::VERSION_OPTION, self::REWRITE_VERSION, true ); } } /** * Public URL of a feed. Falls back to a query string on plain permalinks. * * @param string $slug Feed slug. * @return string */ public static function url( string $slug ): string { if ( get_option( 'permalink_structure' ) ) { return home_url( '/feedweaver/' . $slug . '/' ); } return add_query_arg( self::QUERY_VAR, $slug, home_url( '/' ) ); } /** * The credentials and connection facts the access policy needs, taken from the live request. * * **Credentials are deliberately not passed through WordPress's text sanitizers.** Functions such as * sanitize_text_field() trim whitespace, drop line breaks and strip anything that looks like a tag, which * would turn a malformed credential ("\n", " ", "") into a valid one. Instead the * values are handed on exactly as received, after only being unslashed, to Credential_Extractor, which * accepts a single shape (43 URL-safe characters, optionally behind "Bearer ") and refuses everything * else. Nothing here stores, logs, echoes or uses a credential in a query or in markup, and over-long * values are refused before parsing. * * @return array{authorization:string|false|null,access_key?:mixed} */ public static function credentials_from_globals(): array { $request = array( 'authorization' => self::authorization_header() ); if ( isset( $_GET[ self::KEY_PARAM ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- an access credential, not a form submission; validated by Credential_Extractor. // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a credential: it must not be sanitized (that could repair a malformed one); Credential_Extractor validates it strictly. $request['access_key'] = wp_unslash( $_GET[ self::KEY_PARAM ] ); } return $request; } /** * The Authorization header as the server exposes it to PHP. * * Apache needs the rewrite rule WordPress writes to .htaccess (or equivalent) to pass this * header to PHP; some CGI/FastCGI setups strip it. * * @return string|false|null The header as received; false when it is over-long (the extractor treats any * non-string as malformed); null when there is none. */ private static function authorization_header() { foreach ( array( 'HTTP_AUTHORIZATION', 'REDIRECT_HTTP_AUTHORIZATION' ) as $key ) { if ( isset( $_SERVER[ $key ] ) && is_string( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a credential: it must not be sanitized (that could repair a malformed one); Credential_Extractor validates it strictly. $header = wp_unslash( $_SERVER[ $key ] ); return strlen( $header ) > self::MAX_CREDENTIAL_BYTES ? false : $header; } } if ( function_exists( 'getallheaders' ) ) { foreach ( (array) getallheaders() as $name => $value ) { if ( 0 === strcasecmp( (string) $name, 'Authorization' ) && is_string( $value ) && '' !== $value ) { return strlen( $value ) > self::MAX_CREDENTIAL_BYTES ? false : $value; } } } return null; } /** * Serves a feed when the request targets one. */ public function maybe_serve(): void { $slug = get_query_var( self::QUERY_VAR, null ); if ( null === $slug || '' === $slug ) { return; } if ( ! is_string( $slug ) ) { $slug = ''; // Array-valued query vars (?feedweaver_feed[]=x) are never valid; yields a 404. } $none_match = isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : ''; $request = self::credentials_from_globals(); // Anything printed while building (for example a PHP warning from another plugin) // must not end up inside the feed. ob_start(); $response = $this->controller->respond( $slug, $none_match, $request ); ob_end_clean(); if ( 404 === $response['status'] ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); return; // Let the theme render its normal 404 page. } status_header( $response['status'] ); if ( ! empty( $response['nocache'] ) ) { // The conventional signals page-cache plugins honour. Server-level caches and CDNs // need their own exclusion rules; the headers below are what tell them not to share it. if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the cross-plugin constant page caches look for. } if ( ! defined( 'DONOTCACHEOBJECT' ) ) { define( 'DONOTCACHEOBJECT', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the cross-plugin constant object caches look for. } } foreach ( $response['headers'] as $name => $value ) { header( $name . ': ' . $value ); } echo $response['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- machine-readable XML/JSON, escaped by the renderer. exit; } }