settings = $settings; $this->events = $events; $this->security = $security; $this->timeline = $timeline; } /** Routes only: the cache filter is attached by Track_Module, unconditionally. */ public function init() { add_action( 'rest_api_init', array( $this, 'routes' ) ); } public function routes() { register_rest_route( 'careback/v1', '/track', array( 'methods' => 'POST', 'callback' => array( $this, 'track' ), 'permission_callback' => array( $this, 'permit' ), ) ); } /** * Who may call /track. The tracking page is used without logging in, so * there is no user and no nonce: the request must carry the signed * render-time token (Security::time_token()), checked here before the * handler runs. One that is missing or not signed by this site is refused * with the handler's own uniform answer, timing floor and record (the * handler is called and refuses it before it looks up anything); an old or * a fast one goes on to the handler, which tells those apart. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function permit( \WP_REST_Request $request ) { if ( Security::TIME_BAD !== $this->security->time_token_verdict( (string) $request->get_param( 'time_token' ) ) ) { return true; } $refused = $this->track( $request ); return is_wp_error( $refused ) ? $refused : new \WP_Error( 'careback_track_denied', Texts::get( 'err_no_match', $this->settings ), array( 'status' => 200 ) ); } /** * Never let a cache hold an answer about somebody's order. * * Set in one place for the whole namespace rather than per handler: a route * added later inherits the headers instead of quietly shipping without them. * A CDN configured to cache everything would otherwise serve one customer's * parcel to the next visitor on the same edge — a leak that leaves no trace * in the site's own logs, because the request never reached PHP. */ public function no_store( $response, $server, $request ) { if ( 0 !== strpos( (string) $request->get_route(), '/careback/v1' ) ) { return $response; } $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private' ); $response->header( 'Pragma', 'no-cache' ); $response->header( 'Expires', '0' ); $response->header( 'X-Accel-Expires', '0' ); $response->header( 'X-Robots-Tag', 'noindex, nofollow' ); return $response; } private function enabled() { return ! empty( $this->settings->get( 'track_enabled' ) ) && Plugin::woocommerce_active(); } /** * The one handler. * * Written with a single exit on purpose. Early returns would give each check * its own depth, and eight refusals wearing one message but five different * response times are five different answers to "does this order exist". */ public function track( \WP_REST_Request $request ) { // A site whose tables are not set up yet answers before anything is // counted or logged: there is nowhere to log it and nothing to find, so // the answer says nothing about any order. Switched off, the page answers // as it always does. if ( ! Database::ready() ) { return $this->enabled() ? Rest::not_ready() : new \WP_Error( 'careback_track_denied', Texts::get( 'err_no_match', $this->settings ), array( 'status' => 200 ) ); } $started = microtime( true ); $ip_hash = Hash::ip( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); $country = $this->security->country(); $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; // Without the "#" the order e-mails print in front of the number, before // it is used for anything: one order, one per-order bucket. $number = Order_Lookup::clean_number( sanitize_text_field( (string) $request->get_param( 'order_number' ) ) ); $email = sanitize_email( (string) $request->get_param( 'email' ) ); $honey = trim( (string) $request->get_param( 'company' ) ); $verdict = 'denied'; $timelines = array(); $attempts = (int) $this->settings->get( 'track_max_attempts', 8 ); $window = (int) $this->settings->get( 'track_window_minutes', 15 ); // The number is hashed before it becomes a bucket key: the raw value would // otherwise sit in a transient name, readable by anything that can list // options, which is a list of this shop's real order numbers. $order_key = '' === $number ? '' : Hash::hmac( 'track-order|' . $number ); // Refused before anything is measured or slowed. // // A blocked source is answered and dropped without the timing floor: the // whole point of blocking is that it stops costing the site anything, and // a floor that holds a PHP worker for four hundred milliseconds per // refused request turns the block into the cheapest way to take the site // down. These three answers are all scoped to the requester — this // address, this e-mail — so a 429 says something about them and nothing // about which orders exist. if ( $this->security->is_banned( $ip_hash ) || Rate_Limiter::too_many( 'track_ip', $ip_hash, $attempts, $window ) || Rate_Limiter::too_many( 'track_email', Hash::email( $email ), $attempts * 3, 60 ) ) { if ( Rate_Limiter::first_in_window( 'track_noted', $ip_hash, $window ) ) { $this->events->log( 'rate_limited', $ip_hash, $country, 'track' ); } return new \WP_Error( 'careback_rate_limited', Texts::get( 'err_throttled', $this->settings ), array( 'status' => 429 ) ); } $timing = $this->security->time_token_verdict( (string) $request->get_param( 'time_token' ) ); /* * The per-order allowance is deliberately NOT a 429. * * It is the one bucket keyed by something the attacker is trying to learn * about, so a distinct status code on it answers "does this number * exist" — hammer a candidate, and a real one eventually starts replying * 429 while an invented one never does. It is therefore treated as an * ordinary failed lookup: same status, same message, same timing floor, * and no more information than a wrong e-mail would have given. * * It counts every submitted number, not only the ones that resolve, for * the same reason: a bucket that only fills for real orders is an oracle * whatever is done with it afterwards. */ $order_flooded = '' !== $order_key && Rate_Limiter::too_many( 'track_order', $order_key, $attempts * 2, 60 ); if ( $order_flooded ) { $verdict = 'denied'; } elseif ( ! $this->enabled() ) { $verdict = 'denied'; } elseif ( strlen( $number ) > 32 || strlen( (string) $request->get_param( 'email' ) ) > 254 ) { $verdict = 'denied'; } elseif ( '' !== $honey ) { $this->events->log( 'honeypot', $ip_hash, $country, 'track' ); Rate_Limiter::hit( 'track_ip', $ip_hash, $window ); } elseif ( Security::TIME_OK !== $timing && Security::TIME_STALE !== $timing ) { // A stale token is a customer who left the page open, not a bot, and it // is let through here: this endpoint writes nothing, so replay costs // nothing, and refusing would strand somebody on a cached page. $this->events->log( 'bot_timing', $ip_hash, $country, 'track' ); Rate_Limiter::hit( 'track_ip', $ip_hash, $window ); } elseif ( is_wp_error( $this->security->bot_check( $request->get_param( 'bot_check' ), 'track', $remote ) ) ) { // An add-on's bot check refused it (Security::VERIFY_FILTER): the page's one uniform answer. $this->events->log( 'bot_check_failed', $ip_hash, $country, 'track' ); Rate_Limiter::hit( 'track_ip', $ip_hash, $window ); } else { $order = $this->find( $number ); $matched = $order ? Order_Lookup::email_matches( $order, $email ) : Order_Lookup::absorb_email_check( $email ); if ( $matched && '' !== $number && is_email( $email ) ) { $verdict = 'ok'; $timelines = $this->timeline->build( $order ); // Only failures cost a customer their allowance. Somebody watching // their own parcel refreshes this page for a week — and clearing // the order bucket is also what stops a stranger's guessing from // locking the owner out of their own parcel for an hour. Rate_Limiter::clear( 'track_ip', $ip_hash ); Rate_Limiter::clear( 'track_email', Hash::email( $email ) ); Rate_Limiter::clear( 'track_order', $order_key ); Rest::lookup_result( 'track', true, $order ); do_action( 'careback_track_viewed', $order->get_id(), $ip_hash ); } else { Rate_Limiter::hit( 'track_ip', $ip_hash, $window ); Rate_Limiter::hit( 'track_email', Hash::email( $email ), 60 ); // Every submitted number, real or invented. Counting only the real // ones made this bucket fill for existing orders and never for // others, which is the oracle the branch above exists to close. if ( '' !== $order_key ) { Rate_Limiter::hit( 'track_order', $order_key, 60 ); } Rest::lookup_result( 'track', false ); $this->events->log( 'lookup_failed', $ip_hash, $country, 'track' ); } } // A tracking page is not the returns portal. Failures here must not feed // the shared ban that guards the statutory withdrawal form: somebody who // mistypes an order number while watching a parcel would otherwise lose // the route by which they exercise a legal right. $this->settle( $started ); if ( 'ok' !== $verdict ) { return new \WP_Error( 'careback_track_denied', Texts::get( 'err_no_match', $this->settings ), array( 'status' => 200 ) ); } $payload = array( 'ok' => true, 'timelines' => $this->project( $timelines ), ); return rest_ensure_response( apply_filters( 'careback_track_response', $payload ) ); } /** * Copies the fields the page renders and drops everything else. * * Track_Timeline already builds only what a visitor may see, but four filters * run inside it, and a shop that adds "the customer's name, for the greeting" * to a timeline array would publish it on an endpoint that asks for no login. * A whitelist means such a mistake shows up as a missing greeting rather than * as a data leak. The one hatch left open on purpose is * `careback_track_response`, which runs after this and is the shop's own * decision, taken in its own code. */ private function project( array $timelines ) { $out = array(); foreach ( $timelines as $timeline ) { $timeline = (array) $timeline; $steps = array(); foreach ( (array) ( isset( $timeline['steps'] ) ? $timeline['steps'] : array() ) as $step ) { $step = (array) $step; $steps[] = array( 'key' => isset( $step['key'] ) ? (string) $step['key'] : '', 'label' => isset( $step['label'] ) ? (string) $step['label'] : '', 'state' => in_array( isset( $step['state'] ) ? $step['state'] : '', array( 'done', 'current', 'pending', 'void' ), true ) ? (string) $step['state'] : 'pending', 'date' => isset( $step['date'] ) ? (string) $step['date'] : '', ); } $outcome = (array) ( isset( $timeline['outcome'] ) ? $timeline['outcome'] : array() ); $shipment = (array) ( isset( $timeline['shipment'] ) ? $timeline['shipment'] : array() ); $out[] = array( 'track' => isset( $timeline['track'] ) ? (string) $timeline['track'] : '', 'heading' => isset( $timeline['heading'] ) ? (string) $timeline['heading'] : '', 'steps' => $steps, 'outcome' => empty( $outcome['label'] ) ? array() : array( 'key' => isset( $outcome['key'] ) ? (string) $outcome['key'] : '', 'label' => (string) $outcome['label'], 'date' => isset( $outcome['date'] ) ? (string) $outcome['date'] : '', ), 'note' => isset( $timeline['note'] ) ? (string) $timeline['note'] : '', 'shipment' => array( 'number' => isset( $shipment['number'] ) ? (string) $shipment['number'] : '', 'carrier' => isset( $shipment['carrier'] ) ? (string) $shipment['carrier'] : '', 'url' => isset( $shipment['url'] ) ? esc_url_raw( (string) $shipment['url'] ) : '', ), 'items' => isset( $timeline['items'] ) ? (int) $timeline['items'] : 0, ); } return $out; } /** * Order lookup that costs the same whether or not the order exists. * * The shared lookup returns as soon as the id hits, and falls through to a * meta query only when it misses — so a number that does not exist is the * slower of the two, which is an oracle pointing straight at the numbers that * do. Here both paths always run and the second result is discarded. */ private function find( $number ) { $raw = Order_Lookup::clean_number( $number ); if ( '' === $raw || ! Plugin::woocommerce_active() ) { return false; } // Both lookups always run and the loser is discarded: which of them // answered must not be measurable, or the response time says whether a // number exists. The order of preference below is a choice about // correctness, not about speed. $by_id = ctype_digit( $raw ) ? wc_get_order( absint( $raw ) ) : false; $by_meta = array(); try { $by_meta = wc_get_orders( array( 'limit' => 1, 'return' => 'objects', 'meta_key' => '_order_number', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- the number a numbering plugin shows the customer; one order asked for. 'meta_value' => $raw, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- the number the customer typed. ) ); } catch ( \Throwable $e ) { $by_meta = array(); } // The public number wins. The internal id is accepted only when it is // also the number the shop shows the customer. if ( ! empty( $by_meta ) && $by_meta[0] instanceof \WC_Order ) { return $by_meta[0]; } if ( $by_id instanceof \WC_Order && (string) $by_id->get_order_number() === $raw ) { return $by_id; } return false; } /** * Holds every answer to the same floor before letting it go. * * Jitter alone does not help: random noise leaves the medians where they * were, and a few thousand samples find them. A floor removes the difference * instead of hiding it, and the small jitter on top only blurs the edge. */ private function settle( $started ) { $floor = max( 150, (int) $this->settings->get( 'track_floor_ms', 400 ) ); $spent = ( microtime( true ) - $started ) * 1000; if ( $spent < $floor ) { usleep( (int) ( ( $floor - $spent ) * 1000 ) ); } usleep( wp_rand( 0, 25000 ) ); } }