Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions assets/src/components/SiteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@
/**
* External dependencies
*/
import { useState, useMemo } from 'react';
import {
Button,
Modal,
Notice,
TextControl,
TextareaControl,
Button,
Notice,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useMemo, useState } from 'react';

/**
* Internal dependencies
*/
import { isValidUrl } from '../js/utils';
import type { defaultBrandSite } from '@/admin/settings/page';
import { isValidUrl } from '../js/utils';
interface ErrorsType {
name: string;
url: string;
Expand Down Expand Up @@ -117,6 +117,8 @@ const SiteModal = ( {
headers: {
'Content-Type': 'application/json',
'X-OneSearch-Token': formData.api_key,
'X-OneSearch-Site-URL':
window.OneSearchSettings.currentSiteUrl,
},
}
);
Expand Down
3 changes: 2 additions & 1 deletion inc/Modules/Core/Rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ public function register_hooks(): void {
*/
public function allowed_cors_headers( $headers ): array {
// Skip if the headers are already present.
if ( in_array( 'X-OneSearch-Token', $headers, true ) ) {
if ( in_array( 'X-OneSearch-Token', $headers, true ) && in_array( 'X-OneSearch-Site-URL', $headers, true ) ) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assumption here is that either both headers are present or neither.

IMO that's fine just commenting for future reference ✔️

return $headers;
}
Comment thread
justlevine marked this conversation as resolved.

return array_merge(
$headers,
[
'X-OneSearch-Token',
'X-OneSearch-Site-URL',
]
);
}
Expand Down
81 changes: 52 additions & 29 deletions inc/Modules/Rest/Abstract_REST_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,48 +62,71 @@ public function register_routes(): void {
* @param \WP_REST_Request<array{}> $request Request.
*/
public function check_api_permissions( $request ): bool {
// If it's the same domain, check if the current user can manage options.
$request_origin = $request->get_header( 'origin' );
$request_origin = ! empty( $request_origin ) ? esc_url_raw( wp_unslash( $request_origin ) ) : '';
$parsed_origin = wp_parse_url( $request_origin );
$request_url = ! empty( $parsed_origin['scheme'] ) && ! empty( $parsed_origin['host'] ) ? sprintf(
'%s://%s%s',
$parsed_origin['scheme'],
$parsed_origin['host'],
isset( $parsed_origin['port'] ) ? ':' . $parsed_origin['port'] : ''
) : '';

$origin_port = $parsed_origin['port'] ?? 80;
if ( empty( $request_url ) || $this->is_url_from_host( get_site_url(), $parsed_origin['host'], $origin_port ) ) {
return current_user_can( 'manage_options' );
}
$parsed_origin = is_array( $parsed_origin ) ? $parsed_origin : [];
$request_url = ! empty( $parsed_origin['scheme'] ) && ! empty( $parsed_origin['host'] )
? Utils::normalize_url( $request_origin )
: '';

$origin_port = isset( $parsed_origin['port'] ) ? (int) $parsed_origin['port'] : null;

// See if the `X-OneSearch-Token` header is present.
// Token-based auth takes priority so that cross-site requests from sub-directory
// multisite installs (where Origin loses the path and same-host detection fires
// on sibling sub-sites) are validated by key rather than by logged-in user.
$token = $request->get_header( 'X-OneSearch-Token' );
$token = ! empty( $token ) ? sanitize_text_field( wp_unslash( $token ) ) : '';
if ( empty( $token ) ) {
return false;
}

$stored_key = $this->get_stored_api_key( Utils::normalize_url( $request_url ) );
if ( empty( $stored_key ) || ! hash_equals( $stored_key, $token ) ) {
return false;
}

// Governing sites were checked by ::get_stored_api_key already.
if ( Settings::is_governing_site() ) {
if ( ! empty( $token ) ) {
// When Origin is absent (same-origin browser requests in sub-directory multisite,
// where the browser omits Origin for same-host fetches), fall back to the
// explicitly-sent site URL header so token auth can still proceed.
if ( empty( $request_url ) ) {
$site_url_header = $request->get_header( 'X-OneSearch-Site-URL' );
if ( ! empty( $site_url_header ) ) {
$request_origin = esc_url_raw( wp_unslash( $site_url_header ) );
$parsed_origin = wp_parse_url( $request_origin );
$parsed_origin = is_array( $parsed_origin ) ? $parsed_origin : [];
$request_url = ! empty( $parsed_origin['scheme'] ) && ! empty( $parsed_origin['host'] )
? Utils::normalize_url( $request_origin )
: '';
$origin_port = isset( $parsed_origin['port'] ) ? (int) $parsed_origin['port'] : null;
}
}

if ( empty( $request_url ) ) {
return false;
}

$stored_key = $this->get_stored_api_key( Utils::normalize_url( $request_url ) );
if ( empty( $stored_key ) || ! hash_equals( $stored_key, $token ) ) {
return false;
}

// Governing sites were checked by ::get_stored_api_key already.
if ( Settings::is_governing_site() ) {
return true;
}

// If it's not a healthcheck, compare the origins.
$governing_site_url = Settings::get_parent_site_url();
if ( '/' . $this->namespace . '/health-check' !== $request->get_route() ) {
return ! empty( $governing_site_url ) ? $this->is_url_from_host( $governing_site_url, $parsed_origin['host'], $origin_port ) : false;
}

// For health-checks, if no governing site is set, we set it now.
Settings::set_parent_site_url( $request_origin );
return true;
}

// If it's not a healthcheck, compare the origins.
$governing_site_url = Settings::get_parent_site_url();
if ( '/' . $this->namespace . '/health-check' !== $request->get_route() ) {
return ! empty( $governing_site_url ) ? $this->is_url_from_host( $governing_site_url, $parsed_origin['host'], $origin_port ) : false;
// No token: fall back to same-domain logged-in user check.
if ( empty( $request_url ) || $this->is_url_from_host( get_site_url(), $parsed_origin['host'], $origin_port ) ) {
return current_user_can( 'manage_options' );
}

// For health-checks, if no governing site is set, we set it now.
Settings::set_parent_site_url( $request_origin );
return true;
return false;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions tests/_data/plugins/localhost-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@ static function ( $preempt, array $args, string $url ) {
return $preempt;
}

$parsed = wp_parse_url( $url );
$original_host = $parsed['host'] . ( isset( $parsed['port'] ) ? ':' . $parsed['port'] : '' );

$rewritten_url = str_replace( '://localhost', '://host.docker.internal', $url );

$args['headers']['Host'] = $original_host;

Comment on lines +42 to +48
return wp_remote_request( $rewritten_url, $args );
},
PHP_INT_MAX,
Expand Down
6 changes: 3 additions & 3 deletions tests/phpunit/Integration/Modules/Core/RestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ public function test_allowed_cors_headers_adds_OneSearch_token_once(): void {
$rest = new Rest();

$this->assertSame(
[ 'X-WP-Nonce', 'X-OneSearch-Token' ],
[ 'X-WP-Nonce', 'X-OneSearch-Token', 'X-OneSearch-Site-URL' ],
$rest->allowed_cors_headers( [ 'X-WP-Nonce' ] ),
'Token should be added to headers'
);

$this->assertSame(
[ 'X-OneSearch-Token' ],
$rest->allowed_cors_headers( [ 'X-OneSearch-Token' ] ),
[ 'X-OneSearch-Token', 'X-OneSearch-Site-URL' ],
$rest->allowed_cors_headers( [ 'X-OneSearch-Token', 'X-OneSearch-Site-URL' ] ),
'Token should not be readded'
);
}
Expand Down
Loading