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:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user