Merge pull request #264 from copenhagentruckwash/propose-fix-for-ssrf-in-workfeed-api

Restrict Workfeed API base URL to trusted hosts (prevent SSRF)
This commit is contained in:
Jeppe B
2026-06-02 00:22:35 +02:00
committed by GitHub
3 changed files with 75 additions and 13 deletions
+6 -11
View File
@@ -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
*/
@@ -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, '/');
}
}
@@ -1,5 +1,7 @@
<?php
use workfeed\config\workfeed_api_url_c;
it('uses company-scoped workfeed endpoints and raw Authorization header', function (): void {
$classFile = app_path('classes/workfeed.php');
$content = file_get_contents($classFile);
@@ -24,3 +26,17 @@ it('normalizes documented shift query parameters', function (): void {
expect($content)->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();
});