From d281dddbc1b9208b2d4d613f7f2b1552660db250 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 00:22:18 +0200 Subject: [PATCH] Restrict Workfeed API base URL --- services/nginx/app/classes/workfeed.php | 17 ++---- .../workfeed/config/workfeed_api_url_c.php | 55 ++++++++++++++++++- .../WorkfeedClientConformanceTest.php | 16 ++++++ 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/services/nginx/app/classes/workfeed.php b/services/nginx/app/classes/workfeed.php index 000102d0..e0b22315 100644 --- a/services/nginx/app/classes/workfeed.php +++ b/services/nginx/app/classes/workfeed.php @@ -6,6 +6,7 @@ require_once WD . '/modules/workfeed/workfeed_c.php'; use Exception; use interfaces\workfeed_i; +use workfeed\config\workfeed_api_url_c; use workfeed\workfeed_c; class workfeed implements workfeed_i @@ -84,7 +85,7 @@ class workfeed implements workfeed_i $companyId = $this->requireConfiguredCompanyId(); $url = $this->buildUrl( - $this->config->api_url->getVariableValue(), + workfeed_api_url_c::normalizeApiUrlForValidation((string)$this->config->api_url->getVariableValue()), '/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'), $query ); @@ -182,7 +183,10 @@ class workfeed implements workfeed_i private function requireConfiguredApiUrl(): void { $url = trim((string)$this->config->api_url->getVariableValue()); - if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { + if ($url === '' + || filter_var(workfeed_api_url_c::normalizeApiUrlForValidation($url), FILTER_VALIDATE_URL) === false + || !workfeed_api_url_c::isTrustedApiUrl($url) + ) { throw new Exception('Invalid Workfeed API URL configured.'); } } @@ -210,15 +214,6 @@ class workfeed implements workfeed_i return $companyId; } - private function normalizeUrlForValidation(string $url): string - { - if (preg_match('#^https?://#i', $url)) { - return $url; - } - - return 'https://' . ltrim($url, '/'); - } - /** * @throws Exception */ diff --git a/services/nginx/app/modules/workfeed/config/workfeed_api_url_c.php b/services/nginx/app/modules/workfeed/config/workfeed_api_url_c.php index 8088b0be..2eb69896 100644 --- a/services/nginx/app/modules/workfeed/config/workfeed_api_url_c.php +++ b/services/nginx/app/modules/workfeed/config/workfeed_api_url_c.php @@ -7,7 +7,14 @@ use traits\module_config_variable; class workfeed_api_url_c { - use module_config_variable; + use module_config_variable { + validateVariableValue as private validateModuleConfigVariableValue; + } + + private const TRUSTED_API_HOSTS = [ + 'api.workfeed.io', + 'europe-west1-production-eu-327a3.cloudfunctions.net', + ]; /** * @throws Exception @@ -20,10 +27,54 @@ class workfeed_api_url_c 'string', true, null, - 'The base URL for the Workfeed API (see docs.workfeed.io)', + 'The base URL for the Workfeed API (trusted Workfeed endpoints only; see docs.workfeed.io)', 'https://europe-west1-production-eu-327a3.cloudfunctions.net/api', false, 'https://europe-west1-production-eu-327a3.cloudfunctions.net/api' ); } + + public function validateVariableValue(mixed $value): bool + { + if (!$this->validateModuleConfigVariableValue($value)) { + return false; + } + + return self::isTrustedApiUrl((string)$value); + } + + public static function isTrustedApiUrl(string $url): bool + { + $normalizedUrl = self::normalizeApiUrlForValidation($url); + if (filter_var($normalizedUrl, FILTER_VALIDATE_URL) === false) { + return false; + } + + $parts = parse_url($normalizedUrl); + + if ($parts === false) { + return false; + } + + $scheme = strtolower((string)($parts['scheme'] ?? '')); + $host = strtolower((string)($parts['host'] ?? '')); + $port = $parts['port'] ?? null; + + return $scheme === 'https' + && in_array($host, self::TRUSTED_API_HOSTS, true) + && ($port === null || $port === 443) + && !isset($parts['user']) + && !isset($parts['pass']); + } + + public static function normalizeApiUrlForValidation(string $url): string + { + $url = trim($url); + + if (preg_match('#^https?://#i', $url)) { + return $url; + } + + return 'https://' . ltrim($url, '/'); + } } diff --git a/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php b/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php index ff6d6bdf..d0d4e7d8 100644 --- a/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php +++ b/services/nginx/app/tests/Unit/Workfeed/WorkfeedClientConformanceTest.php @@ -1,5 +1,7 @@ toContain('Workfeed shift query requires startFrom.'); expect($content)->toContain('Workfeed shift query requires startTo.'); }); + + +it('only trusts documented workfeed api hosts', function (): void { + expect(workfeed_api_url_c::isTrustedApiUrl('https://api.workfeed.io'))->toBeTrue(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://europe-west1-production-eu-327a3.cloudfunctions.net/api'))->toBeTrue(); + expect(workfeed_api_url_c::isTrustedApiUrl('api.workfeed.io'))->toBeTrue(); + + expect(workfeed_api_url_c::isTrustedApiUrl('http://api.workfeed.io'))->toBeFalse(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://169.254.169.254/latest/meta-data'))->toBeFalse(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://localhost'))->toBeFalse(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://api.workfeed.io.example.com'))->toBeFalse(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://api.workfeed.io:444'))->toBeFalse(); + expect(workfeed_api_url_c::isTrustedApiUrl('https://user:pass@api.workfeed.io'))->toBeFalse(); +});