Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Show the method, URI and user agent of recently blocked requests on the Firewall card.
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,19 @@ export default function RecentBlocks( { blocks }: { blocks: BlockedRequest[] } )
<ul className="jp-protect-recent-blocks">
{ blocks.map( block => (
<li key={ block.id } className="jp-protect-recent-blocks__item">
<Text variant="body-md">{ getBlockLabel( block ) }</Text>
<Stack direction="column" gap="xs" className="jp-protect-recent-blocks__request">
<Text variant="body-md">{ getBlockLabel( block ) }</Text>
{ block.uri && (
<code className="jp-protect-recent-blocks__uri" title={ block.uri }>
{ [ block.method, block.uri ].filter( Boolean ).join( ' ' ) }
</code>
) }
{ block.userAgent && (
<Text variant="body-sm" className="jp-protect-card__muted">
{ block.userAgent }
</Text>
) }
</Stack>
<Text variant="body-sm" className="jp-protect-card__muted">
<time dateTime={ block.timestamp }>
{ dateI18n( 'M j, g:i A', block.timestamp, undefined ) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

&__item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-block: 8px;
Expand All @@ -15,6 +16,17 @@
var(--wpds-color-stroke-surface-neutral);
}
}

&__request {
min-inline-size: 0;
}

&__uri {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}

.jp-protect-firewall-test {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import RecentBlocks from '../recent-blocks';

const block = {
id: 1,
timestamp: '2026-10-09T16:54:03Z',
ruleId: -1,
reason: 'ip block list',
method: null,
uri: null,
userAgent: null,
};

describe( 'RecentBlocks', () => {
it( 'shows the request a block stopped', () => {
render(
<RecentBlocks
blocks={ [ { ...block, method: 'GET', uri: '/?page_id=1', userAgent: 'curl/8.14.1' } ] }
/>
);

expect( screen.getByText( 'GET /?page_id=1' ) ).toBeInTheDocument();
expect( screen.getByText( 'curl/8.14.1' ) ).toBeInTheDocument();
} );

it( 'shows a block logged before request details were kept', () => {
render( <RecentBlocks blocks={ [ block ] } /> );

expect( screen.getByText( 'Blocked IP address' ) ).toBeInTheDocument();
expect( screen.queryByText( /^GET / ) ).not.toBeInTheDocument();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export type BlockedRequest = {
timestamp: string;
ruleId: number;
reason: string;
/** Null for blocks logged before the firewall kept request details. */
method: string | null;
uri: string | null;
userAgent: string | null;
};

export type ManualRulesState = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Blocklog: Store the method, URI and user agent of each blocked request.
76 changes: 72 additions & 4 deletions projects/packages/waf/src/class-waf-blocklog-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class Waf_Blocklog_Manager {

const BLOCKLOG_OPTION_NAME_DAILY_SUMMARY = 'jetpack_waf_blocklog_daily_summary';
const BLOCKLOG_OPTION_NAME_ALL_TIME_BLOCK_COUNT = 'jetpack_waf_all_time_block_count';
const SCHEMA_VERSION_OPTION_NAME = 'jetpack_waf_blocklog_schema_version';

/**
* Version 2 adds the request's method, URI and user agent.
*/
const SCHEMA_VERSION = 2;

/**
* Database connection.
Expand Down Expand Up @@ -106,12 +112,55 @@ public static function create_blocklog_table() {
timestamp datetime NOT NULL,
rule_id BIGINT NOT NULL,
reason longtext NOT NULL,
method varchar(10) NULL,
request_uri text NULL,
user_agent text NULL,
PRIMARY KEY (log_id),
KEY timestamp (timestamp)
)
";

dbDelta( $sql );
update_option( self::SCHEMA_VERSION_OPTION_NAME, self::SCHEMA_VERSION );
}

/**
* Add the columns newer versions log to a table created by an older one.
*
* @since $$next-version$$
*
* @return void
*/
public static function maybe_upgrade_blocklog_table() {
if ( (int) get_option( self::SCHEMA_VERSION_OPTION_NAME, 1 ) < self::SCHEMA_VERSION ) {
self::create_blocklog_table();
}
}

/**
* The method, URI and user agent of a request, trimmed to fit the log. No IP address is kept.
*
* @since $$next-version$$
*
* @param array $server The request's server variables, such as `$_SERVER`.
* @return array{method: string|null, request_uri: string|null, user_agent: string|null}
*/
public static function get_request_details( array $server ) {
$trim = function ( $key, $length ) use ( $server ) {
if ( ! isset( $server[ $key ] ) || ! is_string( $server[ $key ] ) ) {
return null;
}
$value = \stripslashes( $server[ $key ] );
return function_exists( 'mb_strcut' ) ? mb_strcut( $value, 0, $length, 'UTF-8' ) : substr( $value, 0, $length );
};

$method = $trim( 'REQUEST_METHOD', 10 );

return array(
'method' => null === $method ? null : strtoupper( $method ),
'request_uri' => $trim( 'REQUEST_URI', 2048 ),
'user_agent' => $trim( 'HTTP_USER_AGENT', 512 ),
);
}

/**
Expand All @@ -130,9 +179,25 @@ private static function write_blocklog_row( $log_data ) {

global $table_prefix;

$statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
$details = self::get_request_details( $_SERVER );

// Until its upgrade runs, the table has no detail columns; PHP 8.1+ throws on them instead of returning false.
try {
$statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason, rule_id, timestamp, method, request_uri, user_agent) VALUES (?, ?, ?, ?, ?, ?)" );
} catch ( \Exception $e ) {
$statement = false;
}

if ( false !== $statement ) {
$statement->bind_param( 'sissss', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'], $details['method'], $details['request_uri'], $details['user_agent'] );
} else {
$statement = $conn->prepare( "INSERT INTO {$table_prefix}jetpack_waf_blocklog(reason,rule_id, timestamp) VALUES (?, ?, ?)" );
if ( false !== $statement ) {
$statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
}
}

if ( false !== $statement ) {
$statement->bind_param( 'sis', $log_data['reason'], $log_data['rule_id'], $log_data['timestamp'] );
$statement->execute();

if ( $conn->insert_id > 100 ) {
Expand Down Expand Up @@ -375,7 +440,7 @@ public static function get_all_time_block_count() {
* @since $$next-version$$
*
* @param int $limit How many to return.
* @return array<int, array{id: int, timestamp: string, ruleId: int, reason: string}> Timestamps are ISO 8601 UTC.
* @return array<int, array{id: int, timestamp: string, ruleId: int, reason: string, method: string|null, uri: string|null, userAgent: string|null}> Timestamps are ISO 8601 UTC; the request details are null for rows logged before they were kept.
*/
public static function get_recent_blocks( $limit = 10 ) {
global $wpdb;
Expand All @@ -384,7 +449,7 @@ public static function get_recent_blocks( $limit = 10 ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom table, read on demand.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT log_id, timestamp, rule_id, reason FROM {$wpdb->prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT %d",
"SELECT * FROM {$wpdb->prefix}jetpack_waf_blocklog ORDER BY log_id DESC LIMIT %d",
$limit
),
ARRAY_A
Expand All @@ -402,6 +467,9 @@ function ( $row ) {
'timestamp' => str_replace( ' ', 'T', $row['timestamp'] ) . 'Z',
'ruleId' => (int) $row['rule_id'],
'reason' => (string) $row['reason'],
'method' => $row['method'] ?? null,
'uri' => $row['request_uri'] ?? null,
'userAgent' => $row['user_agent'] ?? null,
);
},
$rows
Expand Down
4 changes: 4 additions & 0 deletions projects/packages/waf/src/class-waf-initializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@ public static function update_waf_after_plugin_upgrade( $upgrader, $hook_extra )
* @return bool|WP_Error True if the WAF is up-to-date or was sucessfully updated, WP_Error if the update failed.
*/
public static function check_for_updates() {
if ( Waf_Runner::is_enabled() ) {
Waf_Blocklog_Manager::maybe_upgrade_blocklog_table();
}

if ( get_option( self::NEEDS_UPDATE_OPTION_NAME ) ) {
if ( Waf_Runner::is_supported_environment() ) {
// Compatiblity patch for cases where an outdated WAF_Constants class has been
Expand Down
119 changes: 119 additions & 0 deletions projects/packages/waf/tests/php/unit/WafBlocklogManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

use Automattic\Jetpack\Waf\Waf_Blocklog_Manager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;

/**
Expand Down Expand Up @@ -123,4 +124,122 @@ public function get_var( $option_name ) {
$this->assertSame( 3, Waf_Blocklog_Manager::get_all_time_block_count() );
$this->assertSame( 3, Waf_Blocklog_Manager::get_current_day_block_count() );
}

/**
* Test which request details are kept for a blocked request.
*
* @dataProvider provideRequestDetails
*
* @param array $server The request's server variables.
* @param array $expected The kept details.
*/
#[DataProvider( 'provideRequestDetails' )]
public function testRequestDetails( $server, $expected ) {
$this->assertSame( $expected, Waf_Blocklog_Manager::get_request_details( $server ) );
}

/**
* Data provider for testRequestDetails.
*
* @return array
*/
public static function provideRequestDetails() {
return array(
'a normal request' => array(
array(
'REQUEST_METHOD' => 'post',
'REQUEST_URI' => '/?s=%3Cscript%3E',
'HTTP_USER_AGENT' => 'sqlmap/1.7',
),
array(
'method' => 'POST',
'request_uri' => '/?s=%3Cscript%3E',
'user_agent' => 'sqlmap/1.7',
),
),
'missing values are null' => array(
array(),
array(
'method' => null,
'request_uri' => null,
'user_agent' => null,
),
),
'oversized values are trimmed' => array(
array(
'REQUEST_METHOD' => str_repeat( 'X', 20 ),
'REQUEST_URI' => '/' . str_repeat( 'a', 5000 ),
'HTTP_USER_AGENT' => str_repeat( 'b', 1000 ),
),
array(
'method' => str_repeat( 'X', 10 ),
'request_uri' => '/' . str_repeat( 'a', 2047 ),
'user_agent' => str_repeat( 'b', 512 ),
),
),
);
}

/**
* Rows logged before the table had request details still read back, with the details null.
*
* @runInSeparateProcess
*/
#[RunInSeparateProcess]
public function testRecentBlocksIncludeRequestDetailsWhenLogged() {
define( 'ARRAY_A', 'ARRAY_A' );
$GLOBALS['wpdb'] = new class() {
/** @var string */
public $prefix = 'wp_';

/**
* @param bool $suppress Whether to suppress errors.
*/
public function suppress_errors( $suppress = true ) {
return $suppress;
}

/**
* @param string $query Query.
*/
public function prepare( $query ) {
return $query;
}

/**
* @return array
*/
public function get_results() {
return array(
array(
'log_id' => '2',
'timestamp' => '2026-10-09 16:54:03',
'rule_id' => '-1',
'reason' => 'ip block list',
'method' => 'GET',
'request_uri' => '/?page_id=1',
'user_agent' => 'curl/8.14.1',
),
array(
'log_id' => '1',
'timestamp' => '2026-10-09 16:07:51',
'rule_id' => '-2',
'reason' => 'firewall test',
),
);
}
};

$blocks = Waf_Blocklog_Manager::get_recent_blocks();

$this->assertSame(
array(
'method' => 'GET',
'uri' => '/?page_id=1',
'userAgent' => 'curl/8.14.1',
),
array_intersect_key( $blocks[0], array_flip( array( 'method', 'uri', 'userAgent' ) ) )
);
$this->assertNull( $blocks[1]['uri'] );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: other

Protect: Show the method, URI and user agent of recently blocked requests on the Protect dashboard.
Loading