| @@ -54,8 +54,31 @@ | ||
| 54 | 54 | */ |
| 55 | 55 | public const DEFAULT_ACCEPTED_WINDOWS = 4; |
| 56 | 56 | |
| 57 | 57 | /** |
| 58 | + * Namespace for tokens that authorise a form submission. | |
| 59 | + * | |
| 60 | + * The value is the historical payload prefix, so existing tokens keep verifying | |
| 61 | + * across an upgrade — changing it would reject every token already embedded in | |
| 62 | + * cached HTML. | |
| 63 | + * | |
| 64 | + * @since 2.12.6 | |
| 65 | + */ | |
| 66 | + public const NAMESPACE_SUBMIT = 'srfm_submit'; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Namespace for tokens that authorise the page-view beacon. | |
| 70 | + * | |
| 71 | + * Separate from NAMESPACE_SUBMIT so the two cannot stand in for each other: a | |
| 72 | + * view token scraped from the page must not authorise a submission, and the | |
| 73 | + * view endpoint must not double as an oracle for whether a submit token is | |
| 74 | + * still inside an accepted window. | |
| 75 | + * | |
| 76 | + * @since 2.12.6 | |
| 77 | + */ | |
| 78 | + public const NAMESPACE_VIEW = 'srfm_view'; | |
| 79 | + | |
| 80 | + /** | |
| 58 | 81 | * Generate a submission token for a given form. |
| 59 | 82 | * |
| 60 | 83 | * The token encodes the form ID and the current half-day window, signed |
| 61 | 84 | * with the site's auth salt. It is safe to embed in cached HTML because |
| @@ -61,13 +84,15 @@ | ||
| 61 | 84 | * with the site's auth salt. It is safe to embed in cached HTML because |
| 62 | 85 | * `verify()` accepts several consecutive past windows. |
| 63 | 86 | * |
| 64 | 87 | * @since 2.6.0 |
| 65 | - * @param int $form_id The form post ID. | |
| 88 | + * @since 2.12.6 Added the $namespace parameter. | |
| 89 | + * @param int $form_id The form post ID. | |
| 90 | + * @param string $namespace Purpose the token is minted for. Defaults to form submission. | |
| 66 | 91 | * @return string 64-character lowercase hex HMAC-SHA256 token. |
| 67 | 92 | */ |
| 68 | - public static function generate( int $form_id ): string { | |
| 69 | - return self::sign( $form_id, self::current_window() ); | |
| 93 | + public static function generate( int $form_id, string $namespace = self::NAMESPACE_SUBMIT ): string { | |
| 94 | + return self::sign( $form_id, self::current_window(), $namespace ); | |
| 70 | 95 | } |
| 71 | 96 | |
| 72 | 97 | /** |
| 73 | 98 | * Verify a token submitted with a form. |
| @@ -75,13 +100,15 @@ | ||
| 75 | 100 | * Checks the token against every accepted window, from newest to oldest, |
| 76 | 101 | * using constant-time comparison throughout. |
| 77 | 102 | * |
| 78 | 103 | * @since 2.6.0 |
| 79 | - * @param string $token Token value received from the client. | |
| 80 | - * @param int $form_id Form post ID extracted from the request body. | |
| 104 | + * @since 2.12.6 Added the $namespace parameter. | |
| 105 | + * @param string $token Token value received from the client. | |
| 106 | + * @param int $form_id Form post ID extracted from the request body. | |
| 107 | + * @param string $namespace Purpose the token must have been minted for. | |
| 81 | 108 | * @return bool True if the token is valid for the given form, false otherwise. |
| 82 | 109 | */ |
| 83 | - public static function verify( string $token, int $form_id ): bool { | |
| 110 | + public static function verify( string $token, int $form_id, string $namespace = self::NAMESPACE_SUBMIT ): bool { | |
| 84 | 111 | if ( '' === $token || $form_id <= 0 ) { |
| 85 | 112 | return false; |
| 86 | 113 | } |
| 87 | 114 | |
| @@ -90,9 +117,9 @@ | ||
| 90 | 117 | $accepted = max( 1, min( 14, (int) apply_filters( 'srfm_submit_token_accepted_windows', self::DEFAULT_ACCEPTED_WINDOWS ) ) ); |
| 91 | 118 | |
| 92 | 119 | // Walk backwards through accepted windows; current window first. |
| 93 | 120 | for ( $offset = 0; $offset < $accepted; $offset++ ) { |
| 94 | - if ( hash_equals( self::sign( $form_id, self::current_window() - $offset ), $token ) ) { | |
| 121 | + if ( hash_equals( self::sign( $form_id, self::current_window() - $offset, $namespace ), $token ) ) { | |
| 95 | 122 | return true; |
| 96 | 123 | } |
| 97 | 124 | } |
| 98 | 125 | |
| @@ -119,18 +146,19 @@ | ||
| 119 | 146 | * the window index so that tokens cannot be repurposed across forms or |
| 120 | 147 | * replayed across time windows. |
| 121 | 148 | * |
| 122 | 149 | * @since 2.6.0 |
| 123 | - * @param int $form_id Post ID of the form. | |
| 124 | - * @param int $window Half-day window index. | |
| 150 | + * @param int $form_id Post ID of the form. | |
| 151 | + * @param int $window Half-day window index. | |
| 152 | + * @param string $namespace Purpose prefix; keeps tokens for one action from authorising another. | |
| 125 | 153 | * @return string 64-character lowercase hex digest. |
| 126 | 154 | */ |
| 127 | - private static function sign( int $form_id, int $window ): string { | |
| 155 | + private static function sign( int $form_id, int $window, string $namespace = self::NAMESPACE_SUBMIT ): string { | |
| 128 | 156 | // Derive a plugin-specific sub-key from the site's auth salt so this |
| 129 | 157 | // system has an independent key surface from WordPress session cookies. |
| 130 | 158 | // Rotating wp-config.php secrets invalidates all outstanding tokens, which |
| 131 | 159 | // is intentional — a cache purge should follow any secret key rotation. |
| 132 | 160 | $signing_key = hash_hmac( 'sha256', 'srfm-submit-token-v1', wp_salt( 'auth' ) ); |
| 133 | - $payload = implode( '|', [ 'srfm_submit', $form_id, $window ] ); | |
| 161 | + $payload = implode( '|', [ $namespace, $form_id, $window ] ); | |
| 134 | 162 | return hash_hmac( 'sha256', $payload, $signing_key ); |
| 135 | 163 | } |
| 136 | 164 | } |