Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CORS Proxy] Rate-limits IPv6 requests based on /64 subnets, not specific addresses #1923

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
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
91 changes: 83 additions & 8 deletions packages/playground/website-deployment/cors-proxy-config.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,13 @@ public function obtain_token($remote_ip, $bucket_config) {
return false;
}

// @TODO: Handle IPv6 addresses in a way that cannot lead to storage exhaustion.
$ipv6_remote_ip = $remote_ip;
if (filter_var($remote_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
// Convert IPv4 to IPv6 mapped address for storage
$ipv6_remote_ip = '::ffff:' . $remote_ip;
}
/**
* Aggregate addresses into /64 subnets.
* This prevents a person with an entire /64 subnet from
* exhausting the storage or getting more than their fair
* share of the tokens.
*/
$remote_subnet = playground_ip_to_a_64_subnet($remote_ip);

$token_query = <<<'SQL'
INSERT INTO cors_proxy_rate_limiting (
Expand Down Expand Up @@ -151,7 +152,7 @@ public function obtain_token($remote_ip, $bucket_config) {
mysqli_stmt_bind_param(
$token_statement,
'sii',
$ipv6_remote_ip,
$remote_subnet,
$bucket_config->capacity,
$bucket_config->fill_rate_per_minute
);
Expand All @@ -165,6 +166,7 @@ public function obtain_token($remote_ip, $bucket_config) {

return false;
}

}

function playground_cors_proxy_maybe_rate_limit() {
Expand Down Expand Up @@ -194,4 +196,77 @@ function playground_cors_proxy_maybe_rate_limit() {
} finally {
$token_bucket->dispose();
}
}
}

/**
* Converts the IP address to a key used for rate limiting.
* It groups addresses into buckets of size /64.
*
* @return string The encoded IPv6 address or null if the input is not a valid IP address.
*/
function playground_ip_to_a_64_subnet(string $ip_v4_or_v6): string {
$ipv6_remote_ip = $ip_v4_or_v6;
if (filter_var($ip_v4_or_v6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
/**
* Convert IPv4 to IPv6 mapped address for storage.
* Do not group these addresses into buckets since
* the number of IPv4 addresses is less than the number
* of IPv6 addresses.
*/
$ipv6_remote_ip = '::ffff:' . $ip_v4_or_v6;
} else {
/**
* Zero out the last 64 bits of the IPv6 address for storage.
* This means we're only considering the first 64 bits of the
* address for rate limiting. This way, a person with an entire
* /64 subnet cannot get more than their fair share of the
* tokens.
*/
$ipv6_block = playground_get_ipv6_block($ipv6_remote_ip, 64);
if ($ipv6_block === null) {
error_log('Failed to get IPv6 block for ' . $ip_v4_or_v6);
// Use the original IP address as a fallback when the block
// cannot be determined.
return $ip_v4_or_v6;
}
$ipv6_remote_ip = playground_encode_ipv6($ipv6_block);
}
return $ipv6_remote_ip;
}

/**
* Returns the /64 subnet of the given IPv6 address.
*/
function playground_get_ipv6_block(string $ipv6_remote_ip, int $block_size=64): ?string {
$ip = inet_pton($ipv6_remote_ip);
// $ip is a binary string of length 16, each bit represents a bit
// of the ipv6 address.
if ($ip === false) {
return null;
}

if($block_size % 8 !== 0) {
// We're using a naive substr-based approach that reasons about
// groups of 8 bits (characters) and not separately about each bit.
// This approach can only support block sizes that are multiplies
// of 8.
throw new Exception('Block size must be a multiple of 8.');
}

$subnet_length = $block_size / 8;
$requested_block = substr($ip, 0, $subnet_length);
$backfill_zeros = str_repeat(chr(0), 16 - $subnet_length);
return $requested_block . $backfill_zeros;
}

function playground_encode_ipv6(string $ipv6): string {
$hex_string = bin2hex($ipv6);
$hex_string_length = strlen($hex_string);

// Split the hex string into 4 groups of 4 characters.
$groups = [];
for ($i = 0; $i < $hex_string_length; $i += 4) {
$groups[] = substr($hex_string, $i, 4);
}
return strtoupper(implode(':', $groups));
}
35 changes: 35 additions & 0 deletions packages/playground/website-deployment/tests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

require __DIR__ . '/cors-proxy-config.php';

function assert_equal($expected, $actual, $message='') {
if ($expected !== $actual) {
$message = $message ?: "Test failed.";
echo "$message.\nExpected: $expected\nActual: $actual\n";
die();
}
}

assert_equal(
'2607:B4C0:0000:0000:0000:0000:0000:0000',
playground_ip_to_a_64_subnet(
'2607:B4C0:0000:0000:0000:0000:0000:0001'
),
'IPv6 was not correctly transformed into a subnet'
);

assert_equal(
'2607:B4C0:AAAA:BBBB:0000:0000:0000:0000',
playground_ip_to_a_64_subnet(
'2607:B4C0:AAAA:BBBB:CCCC:DDDD:EEEE:FFFF'
),
'IPv6 was not correctly transformed into a subnet'
);

assert_equal(
'::ffff:127.0.0.1',
playground_ip_to_a_64_subnet('127.0.0.1', 64),
'A part of the IPv4 range was lost'
);

echo 'All tests passed';
Loading