Repository navigation
Expand file tree
/
Copy pathbootstrap.php
More file actions
2066 lines (1795 loc) · 77.3 KB
/
Copy pathbootstrap.php
File metadata and controls
2066 lines (1795 loc) · 77.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* Evaluates a request before WordPress loads.
*
* @package Kanopi\BasicFirewall
*/
/*
* THE EARLY PATH.
*
* Required from wp-config.php, before wp-settings.php. At that moment there is
* no WordPress: no options, no $wpdb, no hooks, no autoloader, no plugins. So
* this file contains no WordPress API calls at all, and it must stay that way
* -- adding one turns a firewall into a fatal error on every request.
*
* Why it exists, given the plugin already runs from an mu-plugin:
*
* host edge cache (Varnish/CDN) <- no PHP runs. Unreachable from here.
* wp-config.php <- THIS FILE
* advanced-cache.php <- Batcache, W3TC, WP Super Cache:
* serves the cached page and exits
* mu-plugins <- the normal path. Never reached above.
* plugins
*
* A page cache serves from `advanced-cache.php` and calls exit() without ever
* loading an mu-plugin. So on exactly the busy, cached site that most needs a
* firewall, the normal path does not run. This one does.
*
* WHAT IT CAN AND CANNOT DO.
*
* The Drupal module documents database-backed block storage as unusable on this
* path, because there is no CMS to read credentials from. That is true of
* Drupal and not of WordPress: `wp-config.php` defines DB_NAME, DB_USER,
* DB_PASSWORD and DB_HOST as plain constants above the line this file is
* required from, so they are already in scope here. Database storage therefore
* works -- see basic_firewall_build_overrides() for how, and for the sidecar
* that supplies the injection paths without needing an option.
*
* Placement is the condition, and it is the one thing to get right: required
* *below* the DB_ constants and immediately above wp-settings.php. Required
* above them, the constants do not exist yet, no connection is built, and a
* site using database storage fails open on every request through this path
* while the admin screens go on reporting the firewall as blocking. Site Health
* checks for exactly that and says so.
*
* What genuinely is not available here is the rest of WordPress: no options, no
* hooks, no $wpdb, no translations. The table names the library writes to are
* baked into the compiled file with the site's prefix already applied, which is
* why storage needs nothing from $wpdb at this point.
*
* `EXCEPTION` MODE.
*
* In every other mode the library sends its own response and exits, so nothing
* here is involved. In `exception` mode it throws the verdict for the host to
* answer, and this file is the host. It answers a block, a lockdown, a redirect
* and a challenge on the spot, with the plugin's own Outcome_Responder loaded
* by hand -- not later from the mu-plugin, because advanced-cache.php runs in
* between, and a page cache would serve the refused visitor the page. The one
* verdict it leaves for WordPress is a solved challenge, which needs settings
* to set the pass cookie; see basic_firewall_answer_outcome().
*
* The plugin being deactivated does not reach this file: whether it is active
* is an option, and there are no options here. Deactivation deletes the
* compiled file instead, so this path stops evaluating in every mode at once.
* A plugin switched off without that hook running -- `active_plugins` edited
* by hand, a database restored from before activation -- leaves the compiled
* file behind, and this path goes on enforcing the last configuration in
* `exception` mode exactly as it would in `block` mode, where the library
* answers without asking anyone. Only a solved challenge, left for a runner
* that never loads, goes unanswered -- and that grants nothing.
*
* Usage -- the Site Health screen prints this with your site's real path
* filled in, because the private directory carries a random per-site suffix:
*
* require_once ABSPATH . 'wp-content/plugins/basic-firewall/bootstrap.php';
* basic_firewall_evaluate( array(
* 'private_path' => '/absolute/path/to/basic-firewall-private-abc123',
* ) );
*
* A site that installed the plugin with Composer and moved its `vendor-dir`
* somewhere other than beside the WordPress root adds the autoloader's path,
* which the status screens also fill in when they can see it:
*
* 'autoloader' => ABSPATH . 'wp-content/mu-plugins/vendor/autoload.php',
*
* Not on a multisite network: see basic_firewall_is_multisite(). There the
* call returns without evaluating, and the mu-plugin evaluates each site
* against its own rules.
*/
if ( ! function_exists( 'basic_firewall_evaluate' ) ) {
/**
* Evaluate the current request against the compiled configuration.
*
* Never throws and never fatals. Every failure allows the request through:
* a firewall that cannot start must not be the reason a site is down.
*
* @param array<string, mixed> $options Bootstrap options. See basic_firewall_options().
*
* @return bool True when the request may continue. False when `exception`
* mode reached a verdict this path left for WordPress to
* answer; every verdict it answers itself ends the request.
*/
function basic_firewall_evaluate( array $options = array() ) {
/*
* The bootstrap's report on itself, written before anything can
* short-circuit. Everything here is a fact only this line is in a
* position to establish, because by the time WordPress loads and
* something asks, the evidence is gone:
*
* `called` -- that wp-config.php actually *calls* this function, not
* merely that it required the file. Nothing downstream can tell those
* apart: `function_exists()` is true either way, and the constant that
* used to stand in for this is also set by the mu-plugin runner, so a
* snippet pasted without its second half reported a healthy early path
* that was never running.
*
* `credentials` -- whether the DB_ constants existed at this moment,
* which is the question of where in wp-config.php the snippet sits.
* They are always defined by the time Site Health asks.
*
* `evaluated` and `reason` are filled in below, once it is known
* whether this request was actually evaluated here or handed onward.
*/
$GLOBALS['basic_firewall_early'] = array(
'called' => true,
'credentials' => defined( 'DB_NAME' ) && defined( 'DB_USER' ) && defined( 'DB_HOST' ),
'evaluated' => false,
'reason' => null,
);
/*
* An `X-Firewall-Mark` the client sent is not a mark. The header is
* where the plugin mirrors the marks it applies, and anything reading
* it cannot tell the two apart, so it goes before anything can
* short-circuit -- whether or not this request is evaluated here. The
* runner does the same on the mu-plugin path; see
* Runner::forget_client_marks().
*/
unset( $_SERVER['HTTP_X_FIREWALL_MARK'] );
$options = basic_firewall_options( $options );
/*
* `responder` -- whether this path can answer an `exception` mode
* verdict itself, or would have to leave it until the mu-plugin loads,
* after a page cache has had the chance to serve the page. Site Health
* reports the second as critical when the mode is `exception`.
*/
$GLOBALS['basic_firewall_early']['responder'] = null !== basic_firewall_responder_file( $options );
if ( ! $options['enabled'] ) {
return basic_firewall_not_evaluated( 'disabled', $options );
}
/*
* Not on a multisite network. One wp-config.php serves every site of
* it, and at this point WordPress has not yet worked out which site a
* request is for -- that is ms-settings.php, well after this line. But
* each site has its own settings, its own compiled file and its own
* private directory, and the glob below finds whichever came first:
* so this path used to evaluate every site's traffic against one
* site's rules, and by marking the request evaluated it stopped the
* mu-plugin applying the right ones.
*
* So it steps aside, and does not mark the request, and the mu-plugin
* evaluates each site against its own rules. Site Health on a network
* says the snippet is doing nothing and can go.
*/
if ( basic_firewall_is_multisite( $options ) ) {
return basic_firewall_not_evaluated( 'multisite', $options );
}
$compiled = basic_firewall_compiled_path( $options );
if ( null === $compiled ) {
return basic_firewall_not_evaluated( 'no-compiled-file', $options );
}
/*
* `compiled` -- which file this path read, and when it was written.
* The compiler may run in another container from the one serving
* requests (WP-CLI through a hosting CLI, say), and a report taken
* there says nothing about what a web container is reading. The
* runner saves this beside its own view of the file, so the two can
* be compared; see Diagnostics. The hash prefix is what makes that
* comparison mean something (#41): a container reading a stale copy
* of the file -- shared storage that has not caught up, a deploy
* that shipped an old one -- has a different hash from the last
* compile's, and the report flags it as a `mismatch`. One hash of a
* file of a few kilobytes, the same one the library is about to read.
*/
$hash = hash_file( 'sha256', $compiled );
$GLOBALS['basic_firewall_early']['compiled'] = array(
'path' => $compiled,
'mtime' => (int) filemtime( $compiled ),
'hash' => is_string( $hash ) ? substr( $hash, 0, 12 ) : null,
);
$runtime = basic_firewall_runtime( $options );
/*
* "Enable the firewall" unticked in the admin. The runner reads that
* setting from an option; this path has no options, so the compiler
* mirrors it into the runtime sidecar. Checked before anything is
* loaded, and before BASIC_FIREWALL_MODE gets a say: a mode pinned in
* wp-config.php chooses how the firewall answers, not whether a
* firewall somebody switched off runs at all.
*/
if ( false === $runtime['enabled'] ) {
return basic_firewall_not_evaluated( 'switched-off', $options );
}
/*
* A role is exempt, and this request carries a WordPress login cookie.
* Whose it is cannot be known until WordPress validates it, so the
* request is left unmarked for the runner, which evaluates it -- or,
* for a member of an exempt role, does not -- once it can. Anything
* without the cookie is evaluated here as usual; see Role_Bypass.
*/
if ( $runtime['defer_login'] && basic_firewall_carries_login_cookie( $options ) ) {
return basic_firewall_not_evaluated( 'deferred-login', $options );
}
/*
* `autoloader` -- which autoloader this path used, and where it came
* from, so the status screens can say which file a failure is about.
* A site whose Composer vendor-dir is somewhere this file cannot guess
* names it in the snippet, and a name that does not resolve is worth
* saying out loud; see basic_firewall_resolve_autoloader().
*/
$autoload = basic_firewall_resolve_autoloader( $options );
$GLOBALS['basic_firewall_early']['autoloader'] = $autoload;
if ( 'unreadable' === $autoload['source'] ) {
return basic_firewall_not_evaluated( 'autoloader-unreadable', $options );
}
if ( 'none' === $autoload['source'] ) {
return basic_firewall_not_evaluated( 'no-autoloader', $options );
}
if ( null !== $autoload['file'] ) {
require_once $autoload['file'];
}
$prefix = basic_firewall_library_prefix();
if ( null === $prefix ) {
return basic_firewall_not_evaluated( 'library-missing', $options );
}
// `library` -- which copy of the library this path runs.
$GLOBALS['basic_firewall_early']['library'] = '' === $prefix ? 'unscoped' : 'scoped';
basic_firewall_define_cache_constants( $options );
// `cache_dir` -- where the library's file caches go on this path,
// which is the only backend it has: the object cache cannot be
// handed to it before WordPress. See Cache_Backend.
$GLOBALS['basic_firewall_early']['cache_dir'] = defined( 'KANOPI_FIREWALL_CACHE_DIR' ) ? (string) constant( 'KANOPI_FIREWALL_CACHE_DIR' ) : null;
basic_firewall_enable_file_secrets( $options );
basic_firewall_set_trusted_proxies( $options );
basic_firewall_apply_redaction( $options, $runtime['redact'] );
/*
* Marks the request as dealt with, so the mu-plugin does not evaluate it
* a second time when WordPress finally loads.
*/
if ( ! defined( 'BASIC_FIREWALL_EVALUATED' ) ) {
define( 'BASIC_FIREWALL_EVALUATED', true );
}
$GLOBALS['basic_firewall_early']['evaluated'] = true;
$class = $prefix . 'Kanopi\\Firewall\\Firewall';
$request = null;
try {
$firewall = call_user_func(
array( $class, 'create' ),
array( $compiled ),
basic_firewall_build_overrides( $options ),
basic_firewall_decision_dispatcher( $options, $runtime['pass_cookie'] )
);
/*
* `mode` -- the mode the firewall this path built is actually in,
* panic file and BASIC_FIREWALL_MODE included. The settings say
* what was asked for; this is what a web request got.
*/
$GLOBALS['basic_firewall_early']['mode'] = basic_firewall_firewall_mode( $firewall );
/*
* `panic` -- whether a panic file is what put the firewall in that
* mode, so a mode that differs from the configured one is reported
* as the override it is rather than flagged as a mismatch.
*/
$GLOBALS['basic_firewall_early']['panic'] = basic_firewall_panic_active( $firewall );
/*
* `failed_rules` -- the rules this path's firewall could not
* construct, which the library skips and logs rather than fatals
* on (#41). A rule that fails only on the web containers -- a
* storage host only they cannot reach, an extension only the CLI
* image has -- showed up nowhere a status screen could see.
*
* Sampled, not asked on every request: the library can only
* answer by building every rule, which undoes its lazy
* construction -- CRS, GeoIP readers and the rest built for a
* visitor an earlier rule had already settled. So it is asked
* with BASIC_FIREWALL_DEBUG on, at most once a minute per
* container otherwise, and on a request that fails (below).
* Asked before evaluating when it is asked, so a request the
* library then refuses and exits on is still reported.
*/
basic_firewall_sample_failed_rules( $firewall, $options, basic_firewall_debug_enabled() ? 'debug' : null );
/*
* The request is built here rather than left to the library, so the
* marks can be read back off it.
*
* A `mark` response flags a request without refusing it -- the
* honeypot case. The library records that as an attribute on the
* Symfony Request, which WordPress knows nothing about, and on this
* path WordPress does not exist yet so there is no hook to fire.
* Stashing it in a global lets the plugin announce it later, once
* there is something listening. Without this, a mark applied on the
* early path is invisible to the entire site.
*
* Built by the plugin's request factory, from the Request class of
* the copy the firewall came from, so a directly requested file
* such as wp-login.php is matched on its own path rather than on
* `/`. See basic_firewall_request().
*/
$request_class = $prefix . 'Symfony\\Component\\HttpFoundation\\Request';
$request = basic_firewall_request( $request_class, $options );
$allowed = $firewall->evaluate( $request );
$marks = $request->attributes->get( 'firewall.marks' );
if ( is_array( $marks ) && array() !== $marks ) {
$GLOBALS['basic_firewall_marks'] = array_values( array_map( 'strval', $marks ) );
}
basic_firewall_send_debug_header();
return $allowed;
} catch ( \Throwable $e ) {
/*
* Every verdict `exception` mode throws lands here -- a block, a
* lockdown, a challenge, a redirect -- alongside every genuine
* failure. The library exits by itself in every other mode, so this
* is only ever `exception` mode or a firewall that could not run.
*
* This used to allow all of it. The mu-plugin never evaluated again
* because BASIC_FIREWALL_EVALUATED was already defined, so a site in
* `exception` mode on this path refused nobody at all.
*
* A genuine failure also samples the rules that failed to
* construct, whether or not a sample was due: the cost of asking
* does not matter on this request, and the answer matters most.
*/
if ( isset( $firewall ) && null === basic_firewall_outcome_kind( $e ) ) {
basic_firewall_sample_failed_rules( $firewall, $options, 'failure' );
}
return basic_firewall_answer_outcome( $e, $request, $options );
}
}
/**
* The current request, as the firewall should see it.
*
* Built by the plugin's Request_Factory, the same class the mu-plugin
* builds its request with, so the two paths cannot see one request
* differently. The request is Symfony's own, unedited: which path the
* rules match for a directly requested file -- wp-login.php, xmlrpc.php,
* a wp-admin screen -- is the library's job, under the compiled
* `path_source: script_name`.
*
* Loaded by hand for the reason Decision_Dispatcher is: the release
* build's autoloader carries the vendored tree and not this plugin's own
* `src/`. A copy of the plugin without the factory still evaluates, on the
* request exactly as Symfony builds it.
*
* @param string $request_class The Request class of the copy the firewall is built from.
* @param array<string, mixed> $options Bootstrap options.
*
* @return object
*/
function basic_firewall_request( $request_class, array $options ) {
$class = 'Kanopi\\BasicFirewall\\Runtime\\Request_Factory';
if ( ! class_exists( $class, false ) ) {
$file = rtrim( (string) $options['plugin_path'], '/' ) . '/src/Runtime/Request_Factory.php';
if ( is_readable( $file ) ) {
require_once $file;
}
}
if ( class_exists( $class, false ) ) {
return call_user_func( array( $class, 'from_globals_of' ), $request_class );
}
return call_user_func( array( $request_class, 'createFromGlobals' ) );
}
/**
* Return without evaluating, and say why.
*
* Records the reason in the bootstrap's report, as every early return
* always has, and -- for a reason that means the snippet is not doing
* the job it was added for -- writes it to the PHP error log as well.
* The report lives only as long as the request, and the status screens
* read the report of *their own* request: an admin screen, or WP-CLI in
* another container altogether. So a web server whose early path never
* evaluated anything could look healthy from every screen (#34).
*
* At most once per interval per reason, because a misconfiguration is
* the same on every request and a log line per request buries it.
*
* @param string $reason Why this request was not evaluated here.
* @param array<string, mixed> $options Bootstrap options.
*
* @return bool Always true: the request continues, for the mu-plugin.
*/
function basic_firewall_not_evaluated( $reason, array $options ) {
$GLOBALS['basic_firewall_early']['reason'] = $reason;
if ( ! in_array( $reason, basic_firewall_benign_reasons(), true ) ) {
$file = (string) ( $GLOBALS['basic_firewall_early']['autoloader']['file'] ?? '' );
basic_firewall_warn_once(
$reason,
sprintf(
'not-evaluated (early): the wp-config.php path did not evaluate this request (%s%s), so the mu-plugin evaluates it instead, after any page cache. Run `wp basic-firewall early-report` for the last web request\'s report. Logged at most once every %d minutes.',
$reason,
in_array( $reason, array( 'autoloader-unreadable', 'library-missing' ), true ) && '' !== $file ? ': ' . $file : '',
(int) ceil( basic_firewall_warn_interval( $options ) / 60 )
),
$options
);
}
basic_firewall_send_debug_header();
return true;
}
/**
* Reasons for not evaluating that are the configuration working as meant.
*
* `disabled` and `switched-off` are the firewall being off on both paths,
* which the operating-mode checks report. `deferred-login` is a request
* with a login cookie on a site with an exempt role, handed to the runner
* on purpose. Everything else means the snippet is present and doing
* nothing. Diagnostics::BENIGN_REASONS must agree with this.
*
* @return list<string>
*/
function basic_firewall_benign_reasons() {
return array( 'disabled', 'switched-off', 'deferred-login' );
}
/**
* How long a not-evaluated warning stays quiet after it is logged, in seconds.
*
* @param array<string, mixed> $options Bootstrap options.
*
* @return int
*/
function basic_firewall_warn_interval( array $options ) {
return isset( $options['warn_interval'] ) && is_numeric( $options['warn_interval'] ) ? max( 0, (int) $options['warn_interval'] ) : 900;
}
/**
* Write a warning to the PHP error log.
*
* The only log reachable before WordPress, and the one a host keeps: the
* firewall's own logger is configured by the compiled file, which is what
* may have failed.
*
* @param string $message What happened.
*
* @return void
*/
function basic_firewall_warn( $message ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- no WordPress and no firewall logger before wp-settings.php.
error_log( 'Basic Firewall [warning]: ' . $message );
}
/**
* Write a warning, unless the same one was written within the interval.
*
* Remembered in a marker file's modification time, in the private
* directory when there is one -- which on a host with several web
* containers is shared storage, so the interval holds across all of them
* -- and in the system temporary directory otherwise. A file rather than
* APCu because APCu is per container and often per worker pool, and it is
* not there at all on plenty of hosts. Checking costs one stat.
*
* A marker that cannot be written means the warning is logged every time,
* which is noisy and never silent.
*
* @param string $key What the warning is about: a reason code.
* @param string $message The warning.
* @param array<string, mixed> $options Bootstrap options.
*
* @return bool True when it was logged.
*/
function basic_firewall_warn_once( $key, $message, array $options ) {
$private = basic_firewall_private_path( $options );
$key = preg_replace( '/[^a-z0-9-]/', '', strtolower( (string) $key ) );
$marker = null !== $private
? $private . '/.warned-' . $key
: rtrim( sys_get_temp_dir(), '/' ) . '/basic-firewall-warned-' . md5( (string) $options['plugin_path'] ) . '-' . $key;
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- no marker yet is the ordinary answer.
$written = @filemtime( $marker );
if ( false !== $written && time() - $written < basic_firewall_warn_interval( $options ) ) {
return false;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- best effort, and WP_Filesystem does not exist on this path.
@touch( $marker );
basic_firewall_warn( $message );
return true;
}
/**
* Write a warning at most once per interval per key, counting the ones held back.
*
* For a warning that can happen on every request -- a fail-open -- where
* the first matters, a line per request buries the log, and how many
* were held back is itself the thing somebody investigating wants. The
* next line written says how many there were since the last:
* `... (12 more since 2026-01-01 12:00:00 UTC)`.
*
* The marker-file approach of basic_firewall_warn_once(), with the state
* in the file rather than its modification time, because a count has to
* be kept somewhere: when the key was last logged, and how many have been
* held back since. Locked while it is read and rewritten, so two workers
* failing at once neither both log nor lose a count. In the private
* directory when there is one, shared across web containers on a host
* that shares it; the system temporary directory otherwise. A marker
* that cannot be opened means the warning is logged every time -- noisy,
* never silent. Diagnostics::warn_throttled() writes the same file on the
* runner path.
*
* @param string $key What the warning is about, e.g. where, class and origin.
* @param string $message The warning.
* @param array<string, mixed> $options Bootstrap options.
*
* @return bool True when it was logged.
*/
function basic_firewall_warn_throttled( $key, $message, array $options ) {
$interval = isset( $options['fail_open_interval'] ) && is_numeric( $options['fail_open_interval'] ) ? max( 0, (int) $options['fail_open_interval'] ) : 60;
$private = basic_firewall_private_path( $options );
$name = 'fail-open-' . substr( md5( (string) $key ), 0, 16 );
$marker = null !== $private
? $private . '/.warned-' . $name
: rtrim( sys_get_temp_dir(), '/' ) . '/basic-firewall-warned-' . md5( (string) $options['plugin_path'] ) . '-' . $name;
// phpcs:disable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged -- no WP_Filesystem on this path, and a marker that cannot be opened is not an error.
$handle = @fopen( $marker, 'c+' );
if ( false === $handle ) {
basic_firewall_warn( $message );
return true;
}
@flock( $handle, LOCK_EX );
$state = json_decode( (string) stream_get_contents( $handle ), true );
$logged_at = is_array( $state ) ? (int) ( $state['logged'] ?? 0 ) : 0;
$suppressed = is_array( $state ) ? (int) ( $state['suppressed'] ?? 0 ) : 0;
$now = time();
$write = $logged_at <= 0 || $now - $logged_at >= $interval;
if ( $write ) {
if ( $suppressed > 0 ) {
$message .= sprintf( ' (%d more since %s UTC)', $suppressed, gmdate( 'Y-m-d H:i:s', $logged_at ) );
}
basic_firewall_warn( $message );
$logged_at = $now;
$suppressed = 0;
} else {
++$suppressed;
}
ftruncate( $handle, 0 );
rewind( $handle );
fwrite(
$handle,
(string) json_encode(
array(
'logged' => $logged_at,
'suppressed' => $suppressed,
)
)
); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- no WordPress yet.
@flock( $handle, LOCK_UN );
fclose( $handle );
// phpcs:enable WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors.Discouraged
return $write;
}
/**
* What a throwable was, for a log line or a report.
*
* Class, message and where it was thrown -- not a trace, which is long,
* mostly the library's own frames, and carries argument values. The
* message has any `user:password@` in a URL masked, since a storage
* backend that cannot connect may name the DSN it tried, and it is cut
* short so one exception cannot fill a log line or a header.
*
* @param \Throwable $e What was thrown.
*
* @return array{class: string, message: string, origin: string}
*/
function basic_firewall_describe_throwable( \Throwable $e ) {
$message = (string) preg_replace( '#(://[^/\s:@]*):[^@\s/]*@#', '$1:***@', $e->getMessage() );
$message = trim( (string) preg_replace( '/\s+/', ' ', $message ) );
if ( strlen( $message ) > 300 ) {
$message = substr( $message, 0, 300 ) . '...';
}
return array(
'class' => get_class( $e ),
'message' => $message,
'origin' => $e->getFile() . ':' . $e->getLine(),
);
}
/**
* The mode a firewall is actually in, or null when it cannot say.
*
* @param object $firewall The firewall.
*
* @return string|null
*/
function basic_firewall_firewall_mode( $firewall ) {
if ( ! is_callable( array( $firewall, 'getMode' ) ) ) {
return null;
}
$mode = call_user_func( array( $firewall, 'getMode' ) );
return $mode instanceof \BackedEnum ? (string) $mode->value : null;
}
/**
* Whether a panic file is changing a firewall's mode.
*
* @param object $firewall The firewall.
*
* @return bool
*/
function basic_firewall_panic_active( $firewall ) {
if ( ! is_callable( array( $firewall, 'getPanicSwitch' ) ) ) {
return false;
}
$switch = call_user_func( array( $firewall, 'getPanicSwitch' ) );
return is_array( $switch ) && ! empty( $switch['active'] );
}
/**
* Ask a firewall for its failed rules, when this request is one that samples.
*
* Records `failed_rules` (the names, or null when not asked) and
* `failed_rules_sampled` (why it was asked: `debug`, `interval` or
* `failure`, or null) in the bootstrap's report. A request already
* sampled is not asked again.
*
* `interval` is at most once every `failed_rules_interval` seconds (60)
* per container, remembered in a marker file's modification time in the
* private directory, or the system temporary directory without one.
* Checking costs one stat. A marker that cannot be written means asking
* every time: a slower request, never a missing report.
*
* @param object $firewall The firewall.
* @param array<string, mixed> $options Bootstrap options.
* @param string|null $reason `debug` or `failure` to ask regardless, or null to ask only when the interval is due.
*
* @return bool True when it asked.
*/
function basic_firewall_sample_failed_rules( $firewall, array $options, $reason = null ) {
if ( ! empty( $GLOBALS['basic_firewall_early']['failed_rules_sampled'] ) ) {
return false;
}
$GLOBALS['basic_firewall_early']['failed_rules'] = $GLOBALS['basic_firewall_early']['failed_rules'] ?? null;
$GLOBALS['basic_firewall_early']['failed_rules_sampled'] = null;
if ( null === $reason ) {
$interval = isset( $options['failed_rules_interval'] ) && is_numeric( $options['failed_rules_interval'] ) ? max( 0, (int) $options['failed_rules_interval'] ) : 60;
$private = basic_firewall_private_path( $options );
$marker = null !== $private
? $private . '/.sampled-failed-rules-early'
: rtrim( sys_get_temp_dir(), '/' ) . '/basic-firewall-sampled-failed-rules-' . md5( (string) $options['plugin_path'] );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- no marker yet is the ordinary answer.
$sampled = @filemtime( $marker );
if ( false !== $sampled && time() - $sampled < $interval ) {
return false;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions -- best effort, and WP_Filesystem does not exist on this path.
@touch( $marker );
$reason = 'interval';
}
$GLOBALS['basic_firewall_early']['failed_rules'] = basic_firewall_failed_rules( $firewall );
$GLOBALS['basic_firewall_early']['failed_rules_sampled'] = (string) $reason;
return true;
}
/**
* The rules a firewall could not construct, by name, or null when it cannot say.
*
* Each is `bucket/Class:index` -- the bucket it was configured in and the
* library's own name for it, with the namespace dropped so a scoped and
* an unscoped copy name a rule alike. Names only: the constructor's
* message can carry a host or a DSN, and the library already logs it.
*
* Never throws. The library's answer is built by constructing every rule,
* and a constructor that fails with an Error rather than an Exception is
* not caught by the library; that is a failure the evaluation that
* follows reports as a fail-open, so here it is only "cannot say".
* Diagnostics::failed_rules() does the same on the runner path.
*
* @param object $firewall The firewall.
*
* @return list<string>|null
*/
function basic_firewall_failed_rules( $firewall ) {
if ( ! is_callable( array( $firewall, 'getFailedRules' ) ) ) {
return null;
}
try {
$failed = call_user_func( array( $firewall, 'getFailedRules' ) );
} catch ( \Throwable $e ) {
return null;
}
$names = array();
foreach ( is_array( $failed ) ? $failed : array() as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
$plugin = (string) ( $entry['plugin'] ?? '' );
$slash = strrpos( $plugin, '\\' );
$names[] = (string) ( $entry['bucket'] ?? '?' ) . '/' . ( false === $slash ? $plugin : substr( $plugin, $slash + 1 ) );
}
return $names;
}
/**
* Whether BASIC_FIREWALL_DEBUG asks for the diagnostic response header.
*
* @return bool
*/
function basic_firewall_debug_enabled() {
return defined( 'BASIC_FIREWALL_DEBUG' ) && (bool) constant( 'BASIC_FIREWALL_DEBUG' );
}
/**
* The bootstrap's report, compact, for the debug header.
*
* No secrets: no paths but a file name and line, no message text, no
* request data. Diagnostics::compact_early() writes the same shape on the
* runner path.
*
* @return array<string, mixed>
*/
function basic_firewall_debug_report() {
$early = isset( $GLOBALS['basic_firewall_early'] ) && is_array( $GLOBALS['basic_firewall_early'] ) ? $GLOBALS['basic_firewall_early'] : array();
$origin = isset( $early['failure_origin'] ) ? (string) $early['failure_origin'] : '';
return array(
'called' => ! empty( $early['called'] ),
'evaluated' => ! empty( $early['evaluated'] ),
'reason' => $early['reason'] ?? null,
'autoloader' => $early['autoloader']['source'] ?? null,
'library' => $early['library'] ?? null,
'mode' => $early['mode'] ?? null,
'outcome' => $early['outcome'] ?? null,
'failure' => isset( $early['failure'] ) ? strtok( (string) $early['failure'], ':' ) . ( '' !== $origin ? ' @ ' . basename( $origin ) : '' ) : null,
'refused' => ! empty( $early['refused'] ),
'responder' => ! empty( $early['responder'] ),
// Names only, and null when this request did not sample them.
'failed_rules' => isset( $early['failed_rules'] ) && is_array( $early['failed_rules'] ) ? array_values( array_map( 'strval', $early['failed_rules'] ) ) : null,
'failed_rules_sampled' => isset( $early['failed_rules_sampled'] ) ? (string) $early['failed_rules_sampled'] : null,
);
}
/**
* Add the X-Basic-Firewall-Early header, when BASIC_FIREWALL_DEBUG asks.
*
* For troubleshooting only: it tells anybody who can make a request how
* the firewall is deployed. Off unless the constant is defined truthy.
* Sent from every exit of the early path, so it is on the responses the
* bootstrap writes itself and on a page served from a cache that runs
* before the runner; the runner replaces it with a fuller one on any
* request that reaches WordPress.
*
* @return void
*/
function basic_firewall_send_debug_header() {
if ( ! basic_firewall_debug_enabled() || headers_sent() ) {
return;
}
$json = json_encode( array( 'early' => basic_firewall_debug_report() ), JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- no WordPress yet.
if ( is_string( $json ) ) {
header( 'X-Basic-Firewall-Early: ' . $json, true );
}
}
/**
* Answer what `exception` mode threw, or fail open on anything else.
*
* **Answered here, not later.** The tempting design stashes the verdict and
* lets the mu-plugin answer it once WordPress has loaded, which is how a
* mark crosses the gap. For a refusal that is wrong in exactly the case this
* path exists for: advanced-cache.php loads between here and the mu-plugin,
* and on a cache hit it serves the page and exits. The visitor the firewall
* refused would get the page from the cache instead. So a block, a lockdown,
* a redirect and a challenge are answered now, by the same Outcome_Responder
* the normal path uses, loaded by hand the way Decision_Dispatcher is.
*
* **Stashed, for the runner.** A solved challenge is not a refusal. It sets
* the pass cookie, whose name lives in settings, which need WordPress; and
* it is a POST to the challenge path, which no page cache serves. So it is
* left in a global and answered by the runner at `muplugins_loaded`, before
* any ordinary plugin loads.
*
* **Refused, when it cannot be answered.** A verdict the responder cannot
* answer -- the responder missing from this copy of the plugin, throwing,
* or returning instead of ending the request -- is refused here, with a
* plain 503, rather than left for the runner: the runner runs after the
* page cache, and a verdict waved on to it is a page served (#34). See
* basic_firewall_refuse().
*
* A stash nobody answers -- the plugin switched off without its
* deactivation hook running -- fails open. For a solved challenge that
* grants nothing: the visitor has no pass cookie and is challenged again.
*
* Anything that is not a verdict is a failure of the firewall rather than
* a decision about the request, and fails open exactly as it always has.
*
* @param \Throwable $outcome What the firewall threw.
* @param object|null $request The request it was about, when it got that far.
* @param array<string, mixed> $options Bootstrap options.
*
* @return bool True when the request may continue.
*/
function basic_firewall_answer_outcome( \Throwable $outcome, $request, array $options ) {
$kind = basic_firewall_outcome_kind( $outcome );
if ( null === $kind ) {
/*
* Failed open, and said so. The runner takes this up once
* WordPress loads, so Site Health reports the failure rather
* than a firewall that evaluated this request and allowed it.
*
* And logged. The report above only lives as long
* as this request, and reaches Site Health only when this
* request is the one Site Health is rendering -- so a firewall
* that failed on every visitor's request and never on an
* administrator's left no trace anywhere (#34). The PHP error
* log is the one place this path can write to that somebody
* investigating later will read.
*
* At most once a minute for the same exception from the same
* place (#41), with a count of the ones held back: a failure that
* persists -- a storage outage on a busy site -- would otherwise
* write a line per request and bury everything else in the log.
* The first is always written.
*/
$failure = basic_firewall_describe_throwable( $outcome );
$GLOBALS['basic_firewall_early']['failure'] = $failure['class'] . ': ' . $failure['message'];
$GLOBALS['basic_firewall_early']['failure_origin'] = $failure['origin'];
basic_firewall_warn_throttled(
'early|' . $failure['class'] . '|' . $failure['origin'],
sprintf(
'fail-open (early): the firewall threw %s "%s" at %s on the wp-config.php path, so the request was let through unfiltered.',
$failure['class'],
$failure['message'],
$failure['origin']
),
$options
);
basic_firewall_send_debug_header();
return true;
}
$GLOBALS['basic_firewall_early']['outcome'] = $kind;
$GLOBALS['basic_firewall_outcome'] = array(
'outcome' => $outcome,
'request' => $request,
);
if ( 'solved' === $kind ) {
return false;
}
$responder = basic_firewall_outcome_responder( $options );
if ( null === $responder ) {
/*
* This copy of the plugin has no responder to answer with. The
* verdict used to be left for the runner, which answers it once
* WordPress loads -- after advanced-cache.php, which on a cache
* hit serves the page and exits first. A refusal now, plain as it
* is, never serves the page. Site Health says why it was plain.
*/
basic_firewall_refuse( $kind, 'the plugin\'s responder is missing from this copy of the plugin' );
return false;
}
unset( $GLOBALS['basic_firewall_outcome'] );
// Queued before the responder writes anything, which it does not replace.
basic_firewall_send_debug_header();
try {
// Ends the request for every verdict it is handed.
$responder->respond( $outcome, $request );
/*
* Reaching this line means it did not: the responder took the
* verdict for something that lets the request continue. #34 was
* a challenge the page was served in place of, with nothing
* logged, and this line returning the responder's `true` is one
* way that happens. A verdict is never a reason to serve the page.
*/
$why = 'the responder returned without answering it';
} catch ( \Throwable $e ) {
/*
* This used to hand the verdict to the runner, which answers it
* after a page cache has had the chance to serve the page -- and
* not at all where nothing loads the plugin before the cache.
*/
$why = 'the responder threw ' . get_class( $e ) . ': ' . $e->getMessage();
}
basic_firewall_refuse( $kind, $why );
return false;
}
/**
* Refuse the request, without WordPress and without the responder.
*
* The last answer to a verdict this path could not answer properly -- no
* responder, a responder that threw, or one that returned instead of
* ending the request. Whatever the verdict was, the visitor gets a
* temporary refusal and not the page: failing open here is what #34
* reported, a challenge rule serving the page it stands in front of, with
* WordPress's cache headers, to be cached at the edge and served to
* everyone after.
*
* Written by hand because anything more capable is what just failed. The
* no-store set is the library's own when it has one; see
* basic_firewall_no_store_headers(). What went wrong goes to the PHP error
* log at warning, the only log reachable before WordPress, and into the