'GET', 'callback' => 'nodium_proxy_script', 'permission_callback' => '__return_true', ) ); register_rest_route( $ns, '/e', array( 'methods' => 'POST', 'callback' => 'nodium_proxy_event', 'permission_callback' => '__return_true', ) ); } add_action( 'rest_api_init', 'nodium_proxy_routes' ); /** The largest script copy kept: Nodium's script is about 17 KB. */ define( 'NODIUM_MAX_SCRIPT', 262144 ); /** * Nodium's script, fetched at most once a day and kept; the last good copy if a fetch fails. * * @return string */ function nodium_script_body() { $js = get_transient( 'nodium_script' ); if ( is_string( $js ) && '' !== $js ) { return $js; } // A fetch that just failed isn't repeated for a minute, so a service that is down doesn't hold up every request. if ( false === get_transient( 'nodium_script_failed' ) ) { $res = wp_remote_get( NODIUM_CDN . '/n.js', array( 'timeout' => 5, 'limit_response_size' => NODIUM_MAX_SCRIPT, ) ); if ( ! is_wp_error( $res ) && 200 === wp_remote_retrieve_response_code( $res ) ) { $js = wp_remote_retrieve_body( $res ); // The script is JavaScript, never markup (an error page), and never cut short by the size limit. if ( '' !== $js && strlen( $js ) < NODIUM_MAX_SCRIPT && '<' !== substr( ltrim( $js ), 0, 1 ) ) { set_transient( 'nodium_script', $js, DAY_IN_SECONDS ); update_option( 'nodium_script_copy', $js, false ); return $js; } } set_transient( 'nodium_script_failed', '1', MINUTE_IN_SECONDS ); } // Try again in an hour, with the last copy meanwhile. $copy = get_option( 'nodium_script_copy', '' ); $copy = is_string( $copy ) ? $copy : ''; if ( '' !== $copy ) { set_transient( 'nodium_script', $copy, HOUR_IN_SECONDS ); } return $copy; } /** * GET …/s.js: the script, as JavaScript (nodium_proxy_serve writes it out). * * @return WP_REST_Response */ function nodium_proxy_script() { $js = nodium_script_body(); $res = new WP_REST_Response( $js, '' === $js ? 503 : 200 ); $res->header( 'Content-Type', 'application/javascript; charset=utf-8' ); // Without a script nothing is cached: a browser or CDN must not keep the 503 for an hour. $res->header( 'Cache-Control', '' === $js ? 'no-store' : 'public, max-age=3600' ); return $res; } /** * Writes the script as itself rather than as JSON. * * @param bool $served Whether the request was already served. * @param WP_HTTP_Response $result The response. * @param WP_REST_Request $request The request. * @return bool */ function nodium_proxy_serve( $served, $result, $request ) { if ( $served || '/' . nodium_proxy_namespace() . '/s.js' !== $request->get_route() ) { return $served; } $body = $result->get_data(); echo is_string( $body ) ? $body : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JavaScript from Nodium's own service, served as JavaScript. return true; } add_filter( 'rest_pre_serve_request', 'nodium_proxy_serve', 10, 3 ); /** Events passed on per visitor address per minute: a busy visitor's, several times over. */ define( 'NODIUM_MAX_EVENTS_PER_MINUTE', 60 ); /** * Whether this address may send another event this minute: a counter in a transient named for a keyed hash of the * address (never the address itself), which expires within the minute. * * @param string $ip The visitor's address. * @return bool */ function nodium_within_rate( $ip ) { $name = 'nodium_rl_' . substr( hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ), 0, 32 ); $now = time(); $state = get_transient( $name ); if ( ! is_array( $state ) || ! isset( $state[0], $state[1] ) || $now - (int) $state[0] >= MINUTE_IN_SECONDS ) { $state = array( $now, 0 ); } if ( (int) $state[1] >= NODIUM_MAX_EVENTS_PER_MINUTE ) { return false; } ++$state[1]; set_transient( $name, $state, max( 1, MINUTE_IN_SECONDS - ( $now - (int) $state[0] ) ) ); return true; } /** * POST …/e: one event, passed on to Nodium with the visitor's address signed. The browser gets an empty 204 whatever * happens, as it would from Nodium. The pass-on waits for the shutdown hook, after the 204 is written, and where the * server can end the request early (PHP-FPM) it does, so no visitor waits on Nodium. * * @param WP_REST_Request $request The request. * @return WP_REST_Response */ function nodium_proxy_event( $request ) { $done = new WP_REST_Response( null, 204 ); $done->header( 'Cache-Control', 'no-store' ); // The script already sends nothing for these browsers; an event posted to the route directly is dropped the same way. if ( ( isset( $_SERVER['HTTP_DNT'] ) && '1' === $_SERVER['HTTP_DNT'] ) || ( isset( $_SERVER['HTTP_SEC_GPC'] ) && '1' === $_SERVER['HTTP_SEC_GPC'] ) ) { return $done; } $key = nodium_parse_key( nodium_key() ); $body = $request->get_body(); $ip = nodium_visitor_ip(); if ( null === $key || '' === $body || strlen( $body ) > NODIUM_MAX_BODY || '' === $ip || ! nodium_within_rate( $ip ) ) { return $done; } $ts = time(); $args = array( 'timeout' => 2, 'headers' => array( 'Content-Type' => 'text/plain', 'Origin' => nodium_home_origin(), 'User-Agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', 'X-Nodium-Forward' => $ip . ';' . $ts . ';' . nodium_sign_forward( $ip, $ts, $key['token'], $key['secret'] ), ), 'body' => $body, ); add_action( 'shutdown', function () use ( $args ) { if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } wp_remote_post( NODIUM_CDN . '/api/v', $args ); } ); return $done; } /** * The signature Nodium checks: HMAC-SHA256 of "ip|ts|site key" under the key's secret, in unpadded base64url. * * @param string $ip The visitor's address. * @param int $ts The time, in seconds. * @param string $token The site key. * @param string $secret The key's secret. * @return string */ function nodium_sign_forward( $ip, $ts, $token, $secret ) { $sig = hash_hmac( 'sha256', $ip . '|' . $ts . '|' . $token, $secret, true ); return rtrim( strtr( base64_encode( $sig ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- the signature's encoding, not obfuscation. } /** * This site's origin, sent as the event's Origin: Nodium counts only events from the site's own domain. * * @return string */ function nodium_home_origin() { $parts = wp_parse_url( home_url() ); $host = isset( $parts['host'] ) ? $parts['host'] : ''; $port = isset( $parts['port'] ) ? ':' . $parts['port'] : ''; $sch = isset( $parts['scheme'] ) ? $parts['scheme'] : 'https'; return $sch . '://' . $host . $port; } /** * An IPv4-mapped IPv6 address (::ffff:a.b.c.d) as the IPv4 address it carries; anything else as it came. * * @param string $ip The address. * @return string */ function nodium_unmap_ip( $ip ) { $bin = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an address that isn't one is returned as it came. if ( false !== $bin && 16 === strlen( $bin ) && str_repeat( "\0", 10 ) . "\xff\xff" === substr( $bin, 0, 12 ) ) { return (string) inet_ntop( substr( $bin, 12 ) ); } return $ip; } /** * A request header's value as one address (the first of a list), or ''. * * @param string $name The $_SERVER key. * @return string */ function nodium_header_ip( $name ) { if ( ! isset( $_SERVER[ $name ] ) ) { return ''; } $first = trim( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $name ] ) ) )[0] ); return filter_var( $first, FILTER_VALIDATE_IP ) ? nodium_unmap_ip( $first ) : ''; } /** * Whether an address could be a visitor's: not private, not reserved, not Cloudflare's. * * @param string $ip The address. * @return bool */ function nodium_is_public_ip( $ip ) { return false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) && ! nodium_is_cloudflare( $ip ); } /** * The visitor's address. If wp-config.php defines NODIUM_IP_HEADER (a $_SERVER key such as 'HTTP_X_REAL_IP', which the * host's own proxy must set itself), that header. Else the connecting address; behind Cloudflare (the connecting * address is one of its published ranges), CF-Connecting-IP; behind the host's own proxy (a private address), the last * public address in X-Forwarded-For. A proxy appends the address it saw, so the entries to the left are the visitor's * to forge and only those the host's own proxies added, on the right, can be trusted. * * @return string */ function nodium_visitor_ip() { $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! filter_var( $remote, FILTER_VALIDATE_IP ) ) { return ''; } $remote = nodium_unmap_ip( $remote ); if ( defined( 'NODIUM_IP_HEADER' ) && is_string( NODIUM_IP_HEADER ) && preg_match( '/^HTTP_[A-Z0-9_]+$/', NODIUM_IP_HEADER ) ) { $set = nodium_header_ip( NODIUM_IP_HEADER ); if ( '' !== $set ) { return $set; } } if ( nodium_is_cloudflare( $remote ) ) { $cf = nodium_header_ip( 'HTTP_CF_CONNECTING_IP' ); if ( '' !== $cf ) { return $cf; } } if ( ! nodium_is_public_ip( $remote ) && isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { $chain = array_reverse( explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) ) ); foreach ( $chain as $entry ) { $entry = trim( $entry ); if ( filter_var( $entry, FILTER_VALIDATE_IP ) ) { $entry = nodium_unmap_ip( $entry ); if ( nodium_is_public_ip( $entry ) ) { return $entry; } } } } return $remote; } /** * Whether an address is in one of the ranges. * * @param string $ip The address. * @param string[] $ranges CIDR ranges. * @return bool */ function nodium_ip_in( $ip, $ranges ) { $bin = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an address that isn't one is simply not in any range. if ( false === $bin ) { return false; } // ::ffff:a.b.c.d is the IPv4 address a.b.c.d, which is what the IPv4 ranges name. if ( 16 === strlen( $bin ) && str_repeat( "\0", 10 ) . "\xff\xff" === substr( $bin, 0, 12 ) ) { $bin = substr( $bin, 12 ); } foreach ( $ranges as $range ) { list( $net, $bits ) = explode( '/', $range ); $net_bin = @inet_pton( $net ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( false === $net_bin || strlen( $net_bin ) !== strlen( $bin ) ) { continue; } $bits = (int) $bits; $whole = intdiv( $bits, 8 ); if ( substr( $bin, 0, $whole ) !== substr( $net_bin, 0, $whole ) ) { continue; } $rest = $bits % 8; if ( 0 === $rest ) { return true; } $mask = ( 0xff << ( 8 - $rest ) ) & 0xff; if ( ( ord( $bin[ $whole ] ) & $mask ) === ( ord( $net_bin[ $whole ] ) & $mask ) ) { return true; } } return false; } /** * Whether the connecting address is Cloudflare's. * * @param string $ip The address. * @return bool */ function nodium_is_cloudflare( $ip ) { return nodium_ip_in( $ip, nodium_cloudflare_ranges() ); }