diff --git a/.env.example.complete b/.env.example.complete
index c40ab1380..1e2160d07 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 468bb4705..6ece47fd5 100644
--- a/app/Actions/ActivityLogger.php
+++ b/app/Actions/ActivityLogger.php
@@ -40,12 +40,10 @@ class ActivityLogger
*/
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 000000000..3ca4b6e66
--- /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 a164de1fa..53d399abe 100644
--- a/app/Config/app.php
+++ b/app/Config/app.php
@@ -64,6 +64,10 @@ return [
// 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 56a510b10..cba6e40a9 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -57,5 +57,6 @@
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 000000000..928b1ab10 --- /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()); + } +}