From 4e8995c3d0e4c738219fdfa4080b22cb40607bd2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 23 Jul 2022 13:41:29 +0100 Subject: [PATCH] Added ability to adjust stored IP address precision Included tests to cover. For #3560 --- .env.example.complete | 8 ++++ app/Actions/ActivityLogger.php | 4 +- app/Actions/IpFormatter.php | 81 ++++++++++++++++++++++++++++++++++ app/Config/app.php | 4 ++ phpunit.xml | 1 + tests/Actions/AuditLogTest.php | 23 ++++++++++ tests/Unit/IpFormatterTest.php | 32 ++++++++++++++ 7 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 app/Actions/IpFormatter.php create mode 100644 tests/Unit/IpFormatterTest.php diff --git a/.env.example.complete b/.env.example.complete index c40ab1380ff..1e2160d07a4 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -357,3 +357,11 @@ API_REQUESTS_PER_MIN=180 # user identifier (Username or email). LOG_FAILED_LOGIN_MESSAGE=false LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver + +# Alter the precision of IP addresses stored by BookStack. +# Should be a number between 0 and 4, where 4 retains the full IP address +# and 0 completely hides the IP address. As an examples, a value of 2 for the +# IP address '146.191.42.4' would result in '146.191.x.x' being logged. +# For the IP address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as: +# '2001:db8:85a3:8d3:x:x:x:x' +IP_ADDRESS_PRECISION=4 \ No newline at end of file diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php index 468bb47055c..6ece47fd5ba 100644 --- a/app/Actions/ActivityLogger.php +++ b/app/Actions/ActivityLogger.php @@ -40,12 +40,10 @@ public function add(string $type, $detail = '') */ protected function newActivityForUser(string $type): Activity { - $ip = request()->ip() ?? ''; - return (new Activity())->forceFill([ 'type' => strtolower($type), 'user_id' => user()->id, - 'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip, + 'ip' => IpFormatter::fromCurrentRequest()->format(), ]); } diff --git a/app/Actions/IpFormatter.php b/app/Actions/IpFormatter.php new file mode 100644 index 00000000000..3ca4b6e6602 --- /dev/null +++ b/app/Actions/IpFormatter.php @@ -0,0 +1,81 @@ +ip = trim($ip); + $this->precision = max(0, min($precision, 4)); + } + + public function format(): string + { + if (empty($this->ip) || $this->precision === 4) { + return $this->ip; + } + + return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4(); + } + + protected function maskIpv4(): string + { + $exploded = $this->explodeAndExpandIp('.', 4); + $maskGroupCount = min( 4 - $this->precision, count($exploded)); + + for ($i = 0; $i < $maskGroupCount; $i++) { + $exploded[3 - $i] = 'x'; + } + + return implode('.', $exploded); + } + + protected function maskIpv6(): string + { + $exploded = $this->explodeAndExpandIp(':', 8); + $maskGroupCount = min(8 - ($this->precision * 2), count($exploded)); + + for ($i = 0; $i < $maskGroupCount; $i++) { + $exploded[7 - $i] = 'x'; + } + + return implode(':', $exploded); + } + + protected function isIpv6(): bool + { + return strpos($this->ip, ':') !== false; + } + + protected function explodeAndExpandIp(string $separator, int $targetLength): array + { + $exploded = explode($separator, $this->ip); + + while (count($exploded) < $targetLength) { + $emptyIndex = array_search('', $exploded) ?: count($exploded) - 1; + array_splice($exploded, $emptyIndex, 0, '0'); + } + + $emptyIndex = array_search('', $exploded); + if ($emptyIndex !== false) { + $exploded[$emptyIndex] = '0'; + } + + return $exploded; + } + + public static function fromCurrentRequest(): self + { + $ip = request()->ip() ?? ''; + + if (config('app.env') === 'demo') { + $ip = '127.0.0.1'; + } + + return new self($ip, config('app.ip_address_precision')); + } +} \ No newline at end of file diff --git a/app/Config/app.php b/app/Config/app.php index a164de1fa46..53d399abec4 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -64,6 +64,10 @@ // Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured. 'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'), + // Alter the precision of IP addresses stored by BookStack. + // Integer value between 0 (IP hidden) to 4 (Full IP usage) + 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), + // Application timezone for back-end date functions. 'timezone' => env('APP_TIMEZONE', 'UTC'), diff --git a/phpunit.xml b/phpunit.xml index 56a510b101d..cba6e40a983 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -57,5 +57,6 @@ + diff --git a/tests/Actions/AuditLogTest.php b/tests/Actions/AuditLogTest.php index 8266fd972f1..92289cd4ffe 100644 --- a/tests/Actions/AuditLogTest.php +++ b/tests/Actions/AuditLogTest.php @@ -218,4 +218,27 @@ public function test_ip_address_not_logged_in_demo_mode() 'entity_id' => $page->id, ]); } + + public function test_ip_address_respects_precision_setting() + { + config()->set('app.proxies', '*'); + config()->set('app.ip_address_precision', 2); + $editor = $this->getEditor(); + /** @var Page $page */ + $page = Page::query()->first(); + + $this->actingAs($editor)->put($page->getUrl(), [ + 'name' => 'Updated page', + 'html' => '

Updated content

', + ], [ + 'X-Forwarded-For' => '192.123.45.1', + ])->assertRedirect($page->refresh()->getUrl()); + + $this->assertDatabaseHas('activities', [ + 'type' => ActivityType::PAGE_UPDATE, + 'ip' => '192.123.x.x', + 'user_id' => $editor->id, + 'entity_id' => $page->id, + ]); + } } diff --git a/tests/Unit/IpFormatterTest.php b/tests/Unit/IpFormatterTest.php new file mode 100644 index 00000000000..928b1ab1009 --- /dev/null +++ b/tests/Unit/IpFormatterTest.php @@ -0,0 +1,32 @@ +assertEquals('192.123.45.5', (new IpFormatter('192.123.45.5', 4))->format()); + $this->assertEquals('192.123.45.x', (new IpFormatter('192.123.45.5', 3))->format()); + $this->assertEquals('192.123.x.x', (new IpFormatter('192.123.45.5', 2))->format()); + $this->assertEquals('192.x.x.x', (new IpFormatter('192.123.45.5', 1))->format()); + $this->assertEquals('x.x.x.x', (new IpFormatter('192.123.45.5', 0))->format()); + + $ipv6 = '2001:db8:85a3:8d3:1319:8a2e:370:7348'; + $this->assertEquals($ipv6, (new IpFormatter($ipv6, 4))->format()); + $this->assertEquals('2001:db8:85a3:8d3:1319:8a2e:x:x', (new IpFormatter($ipv6, 3))->format()); + $this->assertEquals('2001:db8:85a3:8d3:x:x:x:x', (new IpFormatter($ipv6, 2))->format()); + $this->assertEquals('2001:db8:x:x:x:x:x:x', (new IpFormatter($ipv6, 1))->format()); + $this->assertEquals('x:x:x:x:x:x:x:x', (new IpFormatter($ipv6, 0))->format()); + } + + public function test_shortened_ipv6_addresses_expands_as_expected() + { + $this->assertEquals('2001:0:0:0:0:0:x:x', (new IpFormatter('2001::370:7348', 3))->format()); + $this->assertEquals('2001:0:0:0:0:85a3:x:x', (new IpFormatter('2001::85a3:370:7348', 3))->format()); + $this->assertEquals('2001:0:x:x:x:x:x:x', (new IpFormatter('2001::', 1))->format()); + } +}