Skip to content

Commit b6e5c5a

Browse files
authored
Merge pull request #1784 from WordPress/enhance/1157-speculation-rules-considerations
Implement speculative loading considerations for safer behavior
2 parents d656e11 + 4898dfc commit b6e5c5a

File tree

5 files changed

+173
-59
lines changed

5 files changed

+173
-59
lines changed

plugins/speculation-rules/helper.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,29 @@ function plsr_get_speculation_rules(): array {
2929
$prefixer = new PLSR_URL_Pattern_Prefixer();
3030

3131
$base_href_exclude_paths = array(
32-
$prefixer->prefix_path_pattern( '/wp-login.php', 'site' ),
32+
$prefixer->prefix_path_pattern( '/wp-*.php', 'site' ),
3333
$prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ),
34-
$prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' ),
3534
$prefixer->prefix_path_pattern( '/*', 'uploads' ),
3635
$prefixer->prefix_path_pattern( '/*', 'content' ),
3736
$prefixer->prefix_path_pattern( '/*', 'plugins' ),
3837
$prefixer->prefix_path_pattern( '/*', 'template' ),
3938
$prefixer->prefix_path_pattern( '/*', 'stylesheet' ),
4039
);
4140

41+
/*
42+
* If pretty permalinks are enabled, exclude any URLs with query parameters.
43+
* Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter.
44+
*/
45+
if ( (bool) get_option( 'permalink_structure' ) ) {
46+
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' );
47+
} else {
48+
$base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)_wpnonce=*', 'home' );
49+
}
50+
4251
/**
4352
* Filters the paths for which speculative prerendering should be disabled.
4453
*
4554
* All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard.
46-
* By default, the array includes `/wp-login.php` and `/wp-admin/*`.
4755
*
4856
* If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary.
4957
*

plugins/speculation-rules/hooks.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@
1919
* @since 1.0.0
2020
*/
2121
function plsr_print_speculation_rules(): void {
22+
// Skip speculative loading for logged-in users.
23+
if ( is_user_logged_in() ) {
24+
return;
25+
}
26+
27+
// Skip speculative loading for sites without pretty permalinks, unless explicitly enabled.
28+
if ( ! (bool) get_option( 'permalink_structure' ) ) {
29+
/**
30+
* Filters whether speculative loading should be enabled even though the site does not use pretty permalinks.
31+
*
32+
* Since query parameters are commonly used by plugins for dynamic behavior that can change state, ideally any
33+
* such URLs are excluded from speculative loading. If the site does not use pretty permalinks though, they are
34+
* impossible to recognize. Therefore speculative loading is disabled by default for those sites.
35+
*
36+
* For site owners of sites without pretty permalinks that are certain their site is not using such a pattern,
37+
* this filter can be used to still enable speculative loading at their own risk.
38+
*
39+
* @since n.e.x.t
40+
*
41+
* @param bool $enabled Whether speculative loading is enabled even without pretty permalinks.
42+
*/
43+
$enabled = (bool) apply_filters( 'plsr_enabled_without_pretty_permalinks', false );
44+
45+
if ( ! $enabled ) {
46+
return;
47+
}
48+
}
49+
2250
wp_print_inline_script_tag(
2351
(string) wp_json_encode( plsr_get_speculation_rules() ),
2452
array( 'type' => 'speculationrules' )

plugins/speculation-rules/readme.txt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ add_filter(
8282

8383
As mentioned above, adding the `no-prerender` CSS class to a link will prevent it from being prerendered (but not prefetched). Additionally, links with `rel=nofollow` will neither be prefetched nor prerendered because some plugins add this to non-idempotent links (e.g. add to cart); such links ideally should rather be buttons which trigger a POST request or at least they should use `wp_nonce_url()`.
8484

85+
= Are there any special considerations for speculative loading behavior? =
86+
87+
For safety reasons, the entire speculative loading feature is disabled by default for logged-in users and for sites that do not use pretty permalinks. The latter is the case because plugins often use URLs with custom query parameters to let users perform actions, and such URLs should not be speculatively loaded. For sites without pretty permalinks, it is impossible or at least extremely complex to differentiate between which query parameters are Core defaults and which query parameters are custom.
88+
89+
If you are running this plugin on a site without pretty permalinks and are confident that there are no custom query parameters in use that can cause state changes, you can opt in to enabling speculative loading via the `plsr_enabled_without_pretty_permalinks` filter:
90+
91+
`
92+
<?php
93+
add_filter( 'plsr_enabled_without_pretty_permalinks', '__return_true' );
94+
`
95+
8596
= How will this impact analytics and personalization? =
8697

8798
Prerendering can affect analytics and personalization.

plugins/speculation-rules/tests/test-speculation-rules-helper.php

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ public function test_plsr_get_speculation_rules_href_exclude_paths(): void {
5252

5353
$this->assertSameSets(
5454
array(
55-
0 => '/wp-login.php',
56-
1 => '/wp-admin/*',
57-
2 => '/*\\?*(^|&)_wpnonce=*',
58-
3 => '/wp-content/uploads/*',
59-
4 => '/wp-content/*',
60-
5 => '/wp-content/plugins/*',
61-
6 => '/wp-content/themes/stylesheet/*',
62-
7 => '/wp-content/themes/template/*',
55+
'/wp-*.php',
56+
'/wp-admin/*',
57+
'/wp-content/uploads/*',
58+
'/wp-content/*',
59+
'/wp-content/plugins/*',
60+
'/wp-content/themes/stylesheet/*',
61+
'/wp-content/themes/template/*',
62+
'/*\\?*(^|&)_wpnonce=*',
6363
),
6464
$href_exclude_paths,
6565
'Snapshot: ' . var_export( $href_exclude_paths, true )
@@ -79,15 +79,40 @@ static function () {
7979
// Ensure the base exclude paths are still present and that the custom path was formatted correctly.
8080
$this->assertSameSets(
8181
array(
82-
0 => '/wp-login.php',
83-
1 => '/wp-admin/*',
84-
2 => '/*\\?*(^|&)_wpnonce=*',
85-
3 => '/wp-content/uploads/*',
86-
4 => '/wp-content/*',
87-
5 => '/wp-content/plugins/*',
88-
6 => '/wp-content/themes/stylesheet/*',
89-
7 => '/wp-content/themes/template/*',
90-
8 => '/custom-file.php',
82+
'/wp-*.php',
83+
'/wp-admin/*',
84+
'/wp-content/uploads/*',
85+
'/wp-content/*',
86+
'/wp-content/plugins/*',
87+
'/wp-content/themes/stylesheet/*',
88+
'/wp-content/themes/template/*',
89+
'/*\\?*(^|&)_wpnonce=*',
90+
'/custom-file.php',
91+
),
92+
$href_exclude_paths,
93+
'Snapshot: ' . var_export( $href_exclude_paths, true )
94+
);
95+
}
96+
97+
/**
98+
* @covers ::plsr_get_speculation_rules
99+
*/
100+
public function test_plsr_get_speculation_rules_href_exclude_paths_with_pretty_permalinks(): void {
101+
update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
102+
103+
$rules = plsr_get_speculation_rules();
104+
$href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches'];
105+
106+
$this->assertSameSets(
107+
array(
108+
'/wp-*.php',
109+
'/wp-admin/*',
110+
'/wp-content/uploads/*',
111+
'/wp-content/*',
112+
'/wp-content/plugins/*',
113+
'/wp-content/themes/stylesheet/*',
114+
'/wp-content/themes/template/*',
115+
'/*\\?(.+)',
91116
),
92117
$href_exclude_paths,
93118
'Snapshot: ' . var_export( $href_exclude_paths, true )
@@ -118,15 +143,15 @@ static function ( $exclude_paths, $mode ) {
118143
// Also ensure keys are sequential starting from 0 (that is, that array_is_list()).
119144
$this->assertSame(
120145
array(
121-
0 => '/wp-login.php',
122-
1 => '/wp-admin/*',
123-
2 => '/*\\?*(^|&)_wpnonce=*',
124-
3 => '/wp-content/uploads/*',
125-
4 => '/wp-content/*',
126-
5 => '/wp-content/plugins/*',
127-
6 => '/wp-content/themes/stylesheet/*',
128-
7 => '/wp-content/themes/template/*',
129-
8 => '/products/*',
146+
'/wp-*.php',
147+
'/wp-admin/*',
148+
'/wp-content/uploads/*',
149+
'/wp-content/*',
150+
'/wp-content/plugins/*',
151+
'/wp-content/themes/stylesheet/*',
152+
'/wp-content/themes/template/*',
153+
'/*\\?*(^|&)_wpnonce=*',
154+
'/products/*',
130155
),
131156
$href_exclude_paths,
132157
'Snapshot: ' . var_export( $href_exclude_paths, true )
@@ -141,14 +166,14 @@ static function ( $exclude_paths, $mode ) {
141166
// Ensure the additional exclusion is not present because the mode is 'prefetch'.
142167
$this->assertSame(
143168
array(
144-
0 => '/wp-login.php',
145-
1 => '/wp-admin/*',
146-
2 => '/*\\?*(^|&)_wpnonce=*',
147-
3 => '/wp-content/uploads/*',
148-
4 => '/wp-content/*',
149-
5 => '/wp-content/plugins/*',
150-
6 => '/wp-content/themes/stylesheet/*',
151-
7 => '/wp-content/themes/template/*',
169+
'/wp-*.php',
170+
'/wp-admin/*',
171+
'/wp-content/uploads/*',
172+
'/wp-content/*',
173+
'/wp-content/plugins/*',
174+
'/wp-content/themes/stylesheet/*',
175+
'/wp-content/themes/template/*',
176+
'/*\\?*(^|&)_wpnonce=*',
152177
),
153178
$href_exclude_paths,
154179
'Snapshot: ' . var_export( $href_exclude_paths, true )
@@ -177,19 +202,19 @@ static function ( array $exclude_paths ): array {
177202
$actual = plsr_get_speculation_rules()['prerender'][0]['where']['and'][1]['not']['href_matches'];
178203
$this->assertSame(
179204
array(
180-
0 => '/wp-login.php',
181-
1 => '/wp-admin/*',
182-
2 => '/*\\?*(^|&)_wpnonce=*',
183-
3 => '/wp-content/uploads/*',
184-
4 => '/wp-content/*',
185-
5 => '/wp-content/plugins/*',
186-
6 => '/wp-content/themes/stylesheet/*',
187-
7 => '/wp-content/themes/template/*',
188-
8 => '/unshifted/',
189-
9 => '/next/',
190-
10 => '/negative-one/',
191-
11 => '/one-hundred/',
192-
12 => '/letter-a/',
205+
'/wp-*.php',
206+
'/wp-admin/*',
207+
'/wp-content/uploads/*',
208+
'/wp-content/*',
209+
'/wp-content/plugins/*',
210+
'/wp-content/themes/stylesheet/*',
211+
'/wp-content/themes/template/*',
212+
'/*\\?*(^|&)_wpnonce=*',
213+
'/unshifted/',
214+
'/next/',
215+
'/negative-one/',
216+
'/one-hundred/',
217+
'/letter-a/',
193218
),
194219
$actual,
195220
'Snapshot: ' . var_export( $actual, true )
@@ -225,15 +250,15 @@ static function ( array $exclude_paths ): array {
225250
$actual = plsr_get_speculation_rules()['prerender'][0]['where']['and'][1]['not']['href_matches'];
226251
$this->assertSame(
227252
array(
228-
0 => '/wp/wp-login.php',
229-
1 => '/wp/wp-admin/*',
230-
2 => '/blog/*\\?*(^|&)_wpnonce=*',
231-
3 => '/wp-content/uploads/*',
232-
4 => '/wp-content/*',
233-
5 => '/wp-content/plugins/*',
234-
6 => '/wp-content/themes/stylesheet/*',
235-
7 => '/wp-content/themes/template/*',
236-
8 => '/blog/store/*',
253+
'/wp/wp-*.php',
254+
'/wp/wp-admin/*',
255+
'/wp-content/uploads/*',
256+
'/wp-content/*',
257+
'/wp-content/plugins/*',
258+
'/wp-content/themes/stylesheet/*',
259+
'/wp-content/themes/template/*',
260+
'/blog/*\\?*(^|&)_wpnonce=*',
261+
'/blog/store/*',
237262
),
238263
$actual,
239264
'Snapshot: ' . var_export( $actual, true )

plugins/speculation-rules/tests/test-speculation-rules.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ public function test_hooks(): void {
2929
* @covers ::plsr_print_speculation_rules
3030
*/
3131
public function test_plsr_print_speculation_rules_without_html5_support(): void {
32+
$this->enable_pretty_permalinks();
33+
3234
$output = get_echo( 'plsr_print_speculation_rules' );
3335
$this->assertStringContainsString( '<script type="speculationrules">', $output );
3436

@@ -38,6 +40,38 @@ public function test_plsr_print_speculation_rules_without_html5_support(): void
3840
$this->assertArrayHasKey( 'prerender', $rules );
3941
}
4042

43+
/**
44+
* @covers ::plsr_print_speculation_rules
45+
*/
46+
public function test_plsr_print_speculation_rules_without_pretty_permalinks(): void {
47+
$this->disable_pretty_permalinks();
48+
49+
$output = get_echo( 'plsr_print_speculation_rules' );
50+
$this->assertSame( '', $output );
51+
}
52+
53+
/**
54+
* @covers ::plsr_print_speculation_rules
55+
*/
56+
public function test_plsr_print_speculation_rules_without_pretty_permalinks_but_opted_in(): void {
57+
$this->disable_pretty_permalinks();
58+
add_filter( 'plsr_enabled_without_pretty_permalinks', '__return_true' );
59+
60+
$output = get_echo( 'plsr_print_speculation_rules' );
61+
$this->assertStringContainsString( '<script type="speculationrules">', $output );
62+
}
63+
64+
/**
65+
* @covers ::plsr_print_speculation_rules
66+
*/
67+
public function test_plsr_print_speculation_rules_for_logged_in_user(): void {
68+
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
69+
$this->enable_pretty_permalinks();
70+
71+
$output = get_echo( 'plsr_print_speculation_rules' );
72+
$this->assertSame( '', $output );
73+
}
74+
4175
/**
4276
* Test printing the meta generator tag.
4377
*
@@ -49,4 +83,12 @@ public function test_plsr_render_generator_meta_tag(): void {
4983
$this->assertStringContainsString( 'generator', $tag );
5084
$this->assertStringContainsString( 'speculation-rules ' . SPECULATION_RULES_VERSION, $tag );
5185
}
86+
87+
private function enable_pretty_permalinks(): void {
88+
update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' );
89+
}
90+
91+
private function disable_pretty_permalinks(): void {
92+
update_option( 'permalink_structure', '' );
93+
}
5294
}

0 commit comments

Comments
 (0)