403, 'code' => 'FORBIDDEN_IP', ) ); } $api_key = $request->get_header( 'X-GC-API-Key' ); // Check if API key is provided. if ( empty( $api_key ) ) { return new WP_Error( 'gc_missing_api_key', __( 'API key is verplicht. Voeg de X-GC-API-Key header toe.', 'genius-commerce' ), array( 'status' => 401 ) ); } // Validate API key using timing-safe comparison. if ( ! GC_API_Key::validate( $api_key ) ) { return new WP_Error( 'gc_invalid_api_key', __( 'Ongeldige API key.', 'genius-commerce' ), array( 'status' => 401 ) ); } // Check rate limit. if ( ! $this->check_rate_limit( $api_key ) ) { return new WP_Error( 'gc_rate_limit_exceeded', __( 'Rate limit overschreden. Probeer het later opnieuw.', 'genius-commerce' ), array( 'status' => 429 ) ); } // A successful call proves the backend knows this store's key, so the // webhook secret can be fetched now. Heals stores that were coupled via // the één-klik-flow and never returned to WP-admin (throttled + async). GC_Webhook_Manager::maybe_schedule_bootstrap(); return true; } /** * Combined auth-then-scope permission callback (Story 8.3). * * Runs the existing `check_api_key` first (IP allowlist + API-key + * rate-limit) and only then verifies the request has every scope in * `$required_scopes` (AND-semantics — beslissing E uit story 8.3). * * Failure modes: * - Feature-flag `genius_commerce_scope_enforcement_enabled` off → returns `true` * after auth (fallback / cutover mode, beslissing D). * - Route absent from `GC_Scope_Map` (`__UNKNOWN__`) → always 403. * Defense-in-depth: a freshly added controller without scope-entry * must fail closed, not silently allow every caller. * - Cache empty + backend unreachable + no degraded cache → 503 * `SCOPES_UNAVAILABLE` so the admin sees the outage instead of a * cryptic 403. * - Missing one or more scopes → 403 `INSUFFICIENT_SCOPE` with the * list of `missing` scopes in `data.missing` (helps tenant-admin * debug "why is this 403?"). * * @param \WP_REST_Request $request The current REST request. * @param array $required_scopes Scopes required by the route. * @return true|WP_Error */ public function check_scope( WP_REST_Request $request, array $required_scopes ) { $auth = $this->check_api_key( $request ); if ( true !== $auth ) { return $auth; // WP_Error from auth step bubbles up unchanged. } // Cutover safety: enforcement is opt-in. When off we behave like // before story 8.3 was deployed. if ( ! GC_Scopes::is_enforcement_enabled() ) { return true; } // Defense in depth: an unmapped route must never be served. if ( in_array( GC_Scope_Map::UNKNOWN_SCOPE, $required_scopes, true ) ) { Genius_Commerce::log( sprintf( 'Refusing unmapped route: %s %s', $request->get_method(), $request->get_route() ), 'warning' ); return new WP_Error( 'gc_unmapped_route', __( 'Deze REST-route heeft geen scope-mapping en wordt geweigerd.', 'genius-commerce' ), array( 'status' => 403, 'code' => 'INSUFFICIENT_SCOPE', ) ); } // Ensure we have a usable cache. `has_scope` will refresh on miss, // but we need to detect the "no cache + backend down" case up front // so we can return 503 instead of a misleading 403. $cached = GC_Scopes::get_cached(); if ( null === $cached ) { $refreshed = GC_Scopes::refresh_if_stale(); if ( null === $refreshed ) { return new WP_Error( 'gc_scopes_unavailable', __( 'Scope-cache verlopen en Genius Commerce backend niet bereikbaar.', 'genius-commerce' ), array( 'status' => 503, 'code' => 'SCOPES_UNAVAILABLE', ) ); } } // Composite scopes are AND — collect the missing ones for the error. $missing = array(); foreach ( $required_scopes as $scope ) { if ( ! GC_Scopes::has_scope( $scope ) ) { $missing[] = $scope; } } if ( ! empty( $missing ) ) { Genius_Commerce::log( sprintf( 'Insufficient scope on %s %s — missing: %s', $request->get_method(), $request->get_route(), implode( ',', $missing ) ), 'warning' ); return new WP_Error( 'gc_insufficient_scope', sprintf( /* translators: %s: comma-separated list of scopes */ __( 'Deze actie vereist scope: %s', 'genius-commerce' ), implode( ', ', $missing ) ), array( 'status' => 403, 'code' => 'INSUFFICIENT_SCOPE', 'missing' => array_values( $missing ), ) ); } return true; } /** * Check and update rate limit for an API key. * * @param string $api_key The API key. * @return bool True if within rate limit, false if exceeded. */ protected function check_rate_limit( string $api_key ): bool { $key_hash = md5( $api_key ); $transient_key = 'genius_commerce_rate_limit_' . $key_hash; $count = get_transient( $transient_key ); if ( false === $count ) { $count = 0; } if ( $count >= self::RATE_LIMIT ) { return false; } set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS ); return true; } /** * Get collection parameters for pagination. * * @return array Collection parameters. */ public function get_collection_params(): array { return array( 'page' => array( 'description' => __( 'Pagina nummer.', 'genius-commerce' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'per_page' => array( 'description' => __( 'Items per pagina (max 100).', 'genius-commerce' ), 'type' => 'integer', 'default' => self::DEFAULT_PER_PAGE, 'minimum' => 1, 'maximum' => self::MAX_PER_PAGE, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), 'modified_after' => array( 'description' => __( 'Alleen items gewijzigd na deze datum (ISO8601).', 'genius-commerce' ), 'type' => 'string', 'format' => 'date-time', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => array( $this, 'validate_date_param' ), ), ); } /** * Validate a date parameter. * * @param string $value The parameter value. * @param WP_REST_Request $request The request object. * @param string $param The parameter name. * @return bool|WP_Error True if valid, WP_Error otherwise. */ public function validate_date_param( $value, WP_REST_Request $request, string $param ) { if ( empty( $value ) ) { return true; } $date = \DateTime::createFromFormat( \DateTime::ATOM, $value ); if ( ! $date ) { // Try ISO8601 without timezone. $date = \DateTime::createFromFormat( 'Y-m-d\TH:i:s', $value ); } if ( ! $date ) { // Try ISO8601 with milliseconds and Z suffix (JavaScript toISOString format). // e.g., 2026-01-08T20:57:38.150Z $date = \DateTime::createFromFormat( 'Y-m-d\TH:i:s.v\Z', $value ); } if ( ! $date ) { // Fallback: use strtotime which handles most ISO8601 variants. $timestamp = strtotime( $value ); if ( $timestamp !== false ) { $date = new \DateTime(); $date->setTimestamp( $timestamp ); } } if ( ! $date ) { return new WP_Error( 'gc_invalid_date', /* translators: %s: parameter name */ sprintf( __( 'Ongeldige datum voor parameter %s. Gebruik ISO8601 formaat.', 'genius-commerce' ), $param ), array( 'status' => 400 ) ); } return true; } /** * Create a paginated response with headers. * * @param array $data The response data. * @param int $page Current page number. * @param int $per_page Items per page. * @param int $total Total number of items. * @return WP_REST_Response The response object. */ protected function create_paginated_response( array $data, int $page, int $per_page, int $total ): WP_REST_Response { $total_pages = (int) ceil( $total / $per_page ); $response_data = array( 'data' => $data, 'page' => $page, 'per_page' => $per_page, 'total' => $total, ); $response = new WP_REST_Response( $response_data, 200 ); // Add pagination headers (WooCommerce style). $response->header( 'X-WC-Total', $total ); $response->header( 'X-WC-TotalPages', $total_pages ); return $response; } /** * Parse pagination parameters from request. * * @param WP_REST_Request $request The request object. * @return array Array with 'page', 'per_page', and 'offset' keys. */ protected function get_pagination_params( WP_REST_Request $request ): array { $page = $request->get_param( 'page' ) ?: 1; $per_page = $request->get_param( 'per_page' ) ?: self::DEFAULT_PER_PAGE; // Ensure per_page doesn't exceed maximum. $per_page = min( $per_page, self::MAX_PER_PAGE ); return array( 'page' => (int) $page, 'per_page' => (int) $per_page, 'offset' => ( (int) $page - 1 ) * (int) $per_page, ); } /** * Parse modified_after parameter from request. * * @param WP_REST_Request $request The request object. * @return string|null The datetime string or null. */ protected function get_modified_after_param( WP_REST_Request $request ): ?string { $modified_after = $request->get_param( 'modified_after' ); if ( empty( $modified_after ) ) { return null; } return sanitize_text_field( $modified_after ); } /** * Sanitize taxonomy term name/description fields. * * The batch endpoints build sub-requests by hand and call create_item()/ * update_item() directly, bypassing WP_REST_Server::dispatch() and therefore * the route-arg sanitize_callback. Categories and Brands route their * name/description through this helper so the same sanitisation * (sanitize_text_field on name, wp_kses_post on description) runs regardless * of call path — closing the stored-XSS gap on the batch route. * * Null is preserved so update_item() can still tell "field omitted" apart * from "field set to empty". * * @param string|null $name Raw term name, or null when omitted. * @param string|null $description Raw term description, or null when omitted. * @return array{name: string|null, description: string|null} */ protected function sanitize_term_fields( $name, $description ): array { return array( 'name' => null === $name ? null : sanitize_text_field( (string) $name ), 'description' => null === $description ? null : wp_kses_post( (string) $description ), ); } /** * Download a remote image after enforcing the SSRF allowlist (story 10.5). * * Shared by the Products/Categories/Brands/Variations controllers so the * schema + host/IP checks live in exactly one place. Returns the temporary * file path from download_url() on success, or a WP_Error — either from the * validation below or from download_url() itself — so callers keep their * existing `is_wp_error()` handling. * * A permitted host could still 30x-redirect to an internal target after the * pre-flight check passes, so redirects are disabled for the download and * reject_unsafe_urls is re-affirmed; image `src` URLs are expected to be * direct media links, so this does not affect legitimate imports. * * @param string $url The image URL to download. * @return string|\WP_Error Temp file path, or WP_Error on rejection/failure. */ protected function download_remote_image( string $url ) { $valid = $this->validate_remote_image_url( $url ); if ( is_wp_error( $valid ) ) { return $valid; } $harden_args = static function ( $args ) { $args['redirection'] = 0; $args['reject_unsafe_urls'] = true; return $args; }; add_filter( 'http_request_args', $harden_args ); try { return download_url( $url ); } finally { remove_filter( 'http_request_args', $harden_args ); } } /** * Validate a remote image URL against the SSRF allowlist (story 10.5). * * Enforces an https-only scheme and blocks hosts that resolve to internal * address space (RFC1918 10/8, 172.16/12, 192.168/16, loopback 127/8, * link-local 169.254/16 including the cloud metadata IP 169.254.169.254, * 0.0.0.0/8, IPv6 ULA/::1, and other reserved ranges). * * IP literals are checked directly (no DNS needed). Hostnames are resolved * and every A/AAAA record is checked. Resolution failure is treated as * "unknown" and allowed to pass (fail-open) so transient DNS hiccups do not * break legitimate imports; the scheme allowlist and WordPress' * wp_safe_remote_get() remain in force. Note this leaves a DNS-rebinding * residual (a host can resolve to a public IP here and a private IP at * fetch time) — accepted as a defense-in-depth limitation for a LOW finding. * * @param string $url The image URL to validate. * @return true|\WP_Error True when allowed, WP_Error when rejected. */ protected function validate_remote_image_url( string $url ) { $url = trim( $url ); if ( '' === $url ) { return new WP_Error( 'gc_image_url_empty', 'Image URL is empty.' ); } $parsed = wp_parse_url( $url ); if ( false === $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { return new WP_Error( 'gc_image_url_malformed', 'Image URL is malformed.' ); } if ( 'https' !== strtolower( $parsed['scheme'] ) ) { return new WP_Error( 'gc_image_url_scheme', sprintf( 'Only https image URLs are allowed (got "%s").', $parsed['scheme'] ) ); } // Strip brackets from IPv6 literals: "[::1]" -> "::1". $host_ip = trim( $parsed['host'], '[]' ); if ( filter_var( $host_ip, FILTER_VALIDATE_IP ) ) { $ips = array( $host_ip ); } else { $ips = $this->resolve_host_ips( $parsed['host'] ); } foreach ( $ips as $ip ) { if ( $this->is_blocked_ip( $ip ) ) { return new WP_Error( 'gc_image_url_blocked_host', sprintf( 'Image host resolves to a blocked internal address (%s).', $ip ) ); } } return true; } /** * Resolve a hostname to its A/AAAA records (best-effort). * * @param string $host The hostname to resolve. * @return string[] Resolved IP addresses; empty when resolution fails. */ protected function resolve_host_ips( string $host ): array { $ips = array(); $v4 = @gethostbynamel( $host ); if ( is_array( $v4 ) ) { $ips = array_merge( $ips, $v4 ); } if ( function_exists( 'dns_get_record' ) && defined( 'DNS_AAAA' ) ) { $records = @dns_get_record( $host, DNS_AAAA ); if ( is_array( $records ) ) { foreach ( $records as $record ) { if ( ! empty( $record['ipv6'] ) ) { $ips[] = $record['ipv6']; } } } } return array_values( array_unique( $ips ) ); } /** * Whether an IP address falls in a private or reserved range. * * A global/public address survives both filter flags; anything private * (RFC1918, IPv6 ULA) or reserved (loopback 127/8, link-local 169.254/16, * 0.0.0.0/8, ::1, 240.0.0.0/4, ...) fails validation and is blocked. * * @param string $ip The IP address to check. * @return bool True when the IP must be blocked. */ protected function is_blocked_ip( string $ip ): bool { return false === filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } /** * Log an API request for debugging. * * @param string $endpoint The endpoint being called. * @param array $params The request parameters. * @return void */ protected function log_request( string $endpoint, array $params = array() ): void { if ( 'yes' !== get_option( 'genius_commerce_enable_logging', 'no' ) ) { return; } // Redact known PII keys (email, billing/shipping NAW, phone, ...) before // serialising; wc-logs land op disk en kunnen op nginx publiek bereikbaar // zijn als `.htaccess` niet wordt geëvalueerd (story 8.5 / F-004). $safe_params = GC_Log_Redactor::redact( $params ); $message = sprintf( 'REST API Request: %s - Params: %s', $endpoint, wp_json_encode( $safe_params ) ); if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->info( $message, array( 'source' => 'genius-commerce-api' ) ); } } /** * Log debug message for troubleshooting. * * @param string $message The debug message. * @return void */ protected function log_debug( string $message ): void { if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->debug( '[GC-API] ' . $message, array( 'source' => 'genius-commerce-api' ) ); } } }