Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.15% covered (success)
96.15%
50 / 52
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Waf_Self_Check
96.15% covered (success)
96.15%
50 / 52
60.00% covered (warning)
60.00%
3 / 5
29
0.00% covered (danger)
0.00%
0 / 1
 create_token
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 consume_token
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 maybe_block
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 is_ip_allowed
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 get_dir
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * A request the firewall blocks on sight, so site owners can see it working.
4 *
5 * @package automattic/jetpack-waf
6 */
7
8namespace Automattic\Jetpack\Waf;
9
10/**
11 * Blocks a request carrying a one-time token, whatever rules are enabled, unless the IP allow list lets it through.
12 *
13 * The token lives in a file, not an option, because in standalone mode the firewall runs before WordPress.
14 *
15 * @since 0.30.0
16 */
17class Waf_Self_Check {
18
19    const QUERY_PARAM = 'jetpack_waf_test';
20    const RULE_ID     = -2;
21    const REASON      = 'firewall test';
22    const TOKEN_TTL   = 60;
23    const TOKEN_FILE  = '/self-check-token';
24
25    /**
26     * Sent instead of blocking when the request's IP address is on the allow list.
27     */
28    const ALLOWED_HEADER = 'X-JetpackWAF-Test-Allowed';
29
30    /**
31     * Create a token that the next request carrying it gets blocked for, replacing any earlier one.
32     *
33     * @param int $ttl Seconds the token stays valid.
34     * @return string|false The token, or false when it could not be saved.
35     */
36    public static function create_token( $ttl = self::TOKEN_TTL ) {
37        // With the firewall off its constants aren't defined, and the test should still run.
38        if ( defined( 'WP_CONTENT_DIR' ) ) {
39            Waf_Constants::define_waf_directory();
40        }
41
42        $dir = self::get_dir();
43        if ( ! $dir || ( ! is_dir( $dir ) && ! mkdir( $dir, 0755, true ) ) ) {
44            return false;
45        }
46
47        $token = bin2hex( random_bytes( 16 ) );
48        $saved = file_put_contents(
49            $dir . self::TOKEN_FILE,
50            json_encode(
51                array(
52                    'hash'    => hash( 'sha256', $token ),
53                    'expires' => time() + $ttl,
54                ),
55                JSON_UNESCAPED_SLASHES
56            ),
57            LOCK_EX
58        );
59
60        return $saved ? $token : false;
61    }
62
63    /**
64     * Whether the token is the current, unexpired one. A matching token can't be used again.
65     *
66     * @param mixed $token The token from the request, which can be an array.
67     * @return bool
68     */
69    public static function consume_token( $token ) {
70        $dir = self::get_dir();
71        if ( ! $dir || ! is_string( $token ) || '' === $token ) {
72            return false;
73        }
74
75        $path = $dir . self::TOKEN_FILE;
76        if ( ! is_file( $path ) ) {
77            return false;
78        }
79
80        // Unreadable can mean `create_token()` is mid-write, so only an expired token is deleted.
81        $stored = json_decode( (string) file_get_contents( $path ), true );
82        if ( ! is_array( $stored ) || ! isset( $stored['hash'] ) || ! isset( $stored['expires'] ) ) {
83            return false;
84        }
85        if ( $stored['expires'] < time() ) {
86            unlink( $path );
87            return false;
88        }
89
90        if ( ! hash_equals( $stored['hash'], hash( 'sha256', $token ) ) ) {
91            return false;
92        }
93
94        unlink( $path );
95        return true;
96    }
97
98    /**
99     * Block the request when it carries a valid token, unless the IP allow list lets it through.
100     *
101     * @param Waf_Runtime $waf The firewall runtime.
102     * @return void
103     */
104    public static function maybe_block( $waf ) {
105        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The token is the check.
106        if ( ! isset( $_GET[ self::QUERY_PARAM ] ) || ! self::consume_token( $_GET[ self::QUERY_PARAM ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only hashed and compared.
107            return;
108        }
109
110        if ( self::is_ip_allowed( $waf ) ) {
111            if ( ! headers_sent() ) {
112                header( self::ALLOWED_HEADER . ': ip allow list' );
113            }
114            return;
115        }
116
117        $waf->block( 'block', (string) self::RULE_ID, self::REASON );
118    }
119
120    /**
121     * Whether the firewall's rules would stop at the IP allow list for this request.
122     *
123     * @param Waf_Runtime $waf The firewall runtime, which the allow list file reads the request's IP from.
124     * @return bool
125     */
126    private static function is_ip_allowed( $waf ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- The allow list file reads it.
127        $dir = self::get_dir();
128        if ( ! $dir || ! defined( 'JETPACK_WAF_ENTRYPOINT' ) ) {
129            return false;
130        }
131
132        $allow_list = $dir . Waf_Rules_Manager::IP_ALLOW_RULES_FILE;
133        $entrypoint = $dir . '/' . JETPACK_WAF_ENTRYPOINT;
134        if ( ! is_file( $allow_list ) || ! is_file( $entrypoint ) ) {
135            return false;
136        }
137
138        // The generated rules only require the allow list while it's turned on.
139        if ( false === strpos( (string) file_get_contents( $entrypoint ), basename( $allow_list ) ) ) {
140            return false;
141        }
142
143        return (bool) require $allow_list;
144    }
145
146    /**
147     * The firewall's data directory, or null before its constants are set.
148     *
149     * @return string|null
150     */
151    private static function get_dir() {
152        return defined( 'JETPACK_WAF_DIR' ) ? JETPACK_WAF_DIR : null;
153    }
154}