Skip to content

Commit 5892174

Browse files
drmayu7claude
andcommitted
fix: stop URL-containment rejections failing silently, tighten checks
Closes the remaining gaps found in the final review of the SSRF/robustness fixes: - httpGet()/httpPost() now error_log() the rejected URL and configured base (with any embedded credentials stripped) when isWithinBase() refuses a request, instead of just returning false with no trace - previously this was the exact silent-failure mode (empty dropdown, no error anywhere) this PR set out to fix. - isWithinBase() now rejects non-http(s) schemes, so the $baseOverride self-check path used by validateSettings() (isWithinBase($x, $x)) can no longer treat gopher:// or ftp:// as well-formed. Added 3 assertions (59 -> 62). - findValueSet() now returns ['error' => ...] instead of a bare [] on circuit- breaker-open and transport failure, reusing the shape it already used for an unknown search type, and FindValueSetService.php sets HTTP 502 on that shape - mirroring how getValueSetInfo()'s false return already becomes a 502. Verified this can't affect the online designer: its ajax call defines no `error` handler, so a non-2xx response simply means the autocomplete list doesn't update for that keystroke, rather than a 200 that looks like a genuine empty result. - Removed the always-false isset($http_response_header) diagnostic in validateSettings() (that variable belongs to httpPost()'s scope, not this one) and simplified the message it was decorating. - tests/run.php now exits immediately when not run under the CLI SAPI, since it ships inside the module directory under the REDCap web root. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit 6084ecfcd21347898cbef5d53a3bc3e1b0390280)
1 parent dc83556 commit 5892174

4 files changed

Lines changed: 95 additions & 10 deletions

File tree

FhirOntologyAutocompleteExternalModule.php

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,10 @@ public function validateSettings($settings)
180180
// boundary check the way the other call sites use $baseOverride.
181181
$response = $this->httpPost($authEndpoint, $params, 'application/x-www-form-urlencoded', $headers, $authEndpoint);
182182
if ($response === false) {
183-
$r = isset($http_response_header) ? implode("", $http_response_header) : '';
184-
$errors .= "Failed to get Authentication Token for fhir server at '" . $authEndpoint . "' response = false, r='" . $r . "'\n";
183+
// $http_response_header is populated inside httpPost()'s own scope, not
184+
// here, so it is never available at this call site - do not promise a
185+
// response body this diagnostic can never show.
186+
$errors .= "Failed to get Authentication Token for fhir server at '" . $authEndpoint . "' - request failed or was refused\n";
185187
} else {
186188
// a false or unparseable response decodes to null, and array_key_exists(null)
187189
// is a fatal TypeError on PHP 8
@@ -797,8 +799,13 @@ public function findValueSet($type, $query)
797799
return ['error' => "Unknown search type $type"];
798800
}
799801
if ($this->isCircuitOpen()) {
800-
// Server has failed repeatedly - fail fast.
801-
return [];
802+
// Server has failed repeatedly - fail fast. Use the same ['error' => ...]
803+
// shape as the "unknown search type" case above, rather than an empty
804+
// array, so FindValueSetService.php can tell a genuine failure apart
805+
// from "no matches" instead of returning a misleadingly successful
806+
// empty result (see getValueSetInfo(), which does the equivalent with
807+
// a false return).
808+
return ['error' => 'The terminology server is not responding. Please try again shortly.'];
802809
}
803810
$headers = ['User-Agent: Redcap'];
804811
$authHeader = $this->getAuthHeader();
@@ -815,7 +822,7 @@ public function findValueSet($type, $query)
815822
}
816823
if ($result_json === false) {
817824
$this->recordFhirFailureIfSlow(microtime(true) - $startedAt);
818-
return [];
825+
return ['error' => 'The terminology server is not responding. Please try again shortly.'];
819826
}
820827
$this->recordFhirSuccess();
821828
return $processFunction(json_decode($result_json, true));
@@ -942,6 +949,39 @@ public function recordFhirSuccess()
942949
}
943950

944951

952+
/**
953+
* Returns $url with any embedded userinfo (user:pass@) stripped, for safe
954+
* inclusion in log messages. Falls back to the original value if it cannot
955+
* be parsed as a URL.
956+
*/
957+
private function urlForLogging($url)
958+
{
959+
if (!is_string($url) || '' === $url) {
960+
return (string)$url;
961+
}
962+
$parts = parse_url($url);
963+
if (!is_array($parts) || (!isset($parts['user']) && !isset($parts['pass']))) {
964+
return $url;
965+
}
966+
$result = '';
967+
if (isset($parts['scheme'])) {
968+
$result .= $parts['scheme'] . '://';
969+
}
970+
if (isset($parts['host'])) {
971+
$result .= $parts['host'];
972+
}
973+
if (isset($parts['port'])) {
974+
$result .= ':' . $parts['port'];
975+
}
976+
if (isset($parts['path'])) {
977+
$result .= $parts['path'];
978+
}
979+
if (isset($parts['query'])) {
980+
$result .= '?' . $parts['query'];
981+
}
982+
return $result;
983+
}
984+
945985
public function httpGet($fullUrl, $headers, $baseOverride = null)
946986
{
947987
// getFhirServerUri() strips any trailing slash from the configured setting.
@@ -954,6 +994,8 @@ public function httpGet($fullUrl, $headers, $baseOverride = null)
954994
// trivially true.
955995
$base = (null === $baseOverride) ? $this->getFhirServerUri() : $baseOverride;
956996
if (!FhirRequestPolicy::isWithinBase($fullUrl, $base)) {
997+
error_log('FhirOntologyAutocompleteExternalModule: httpGet refused URL outside configured FHIR server - url='
998+
. $this->urlForLogging($fullUrl) . ' base=' . $this->urlForLogging($base));
957999
return false;
9581000
}
9591001
$timeout = $this->getFhirTimeout();
@@ -1003,12 +1045,16 @@ public function httpPost($fullUrl, $postData, $contentType, $headers, $baseOverr
10031045
// trivially true.
10041046
if (null !== $baseOverride) {
10051047
$allowed = FhirRequestPolicy::isWithinBase($fullUrl, $baseOverride);
1048+
$baseForLog = $baseOverride;
10061049
} else {
10071050
$tokenEndpoint = $this->getSystemSetting('cc_token_endpoint');
1051+
$baseForLog = $this->getFhirServerUri();
10081052
$allowed = ($tokenEndpoint && $fullUrl === $tokenEndpoint)
1009-
|| FhirRequestPolicy::isWithinBase($fullUrl, $this->getFhirServerUri());
1053+
|| FhirRequestPolicy::isWithinBase($fullUrl, $baseForLog);
10101054
}
10111055
if (!$allowed) {
1056+
error_log('FhirOntologyAutocompleteExternalModule: httpPost refused URL outside configured FHIR server - url='
1057+
. $this->urlForLogging($fullUrl) . ' base=' . $this->urlForLogging($baseForLog));
10121058
return false;
10131059
}
10141060
$timeout = $this->getFhirTimeout();

FhirRequestPolicy.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,10 @@ public static function opensBreaker($failureCount, $threshold = self::BREAKER_FA
8686
/**
8787
* True when $url addresses the same origin as $baseUri, sits at or below its
8888
* path, carries no embedded credentials other than a copy of the base's own,
89-
* and contains no dot-segment traversal. Every outbound request is checked
90-
* against the configured FHIR server so that a malformed or hostile setting
91-
* cannot turn the module into a proxy for arbitrary hosts on the REDCap
92-
* server's network.
89+
* and contains no dot-segment traversal. Every URL this module constructs
90+
* before handing it to the HTTP helper is checked against this, so a
91+
* malformed or hostile setting cannot make the module request a path or
92+
* host outside the configured FHIR server *at the point this check runs*.
9393
*
9494
* Specifically: scheme, host and port must match; the path must be the base
9595
* or a descendant; and dot-segment traversal (., .., etc.) is rejected in
@@ -103,6 +103,15 @@ public static function opensBreaker($failureCount, $threshold = self::BREAKER_FA
103103
* reject per the Unicode spec (e.g. overlong UTF-8 sequences) are not blocked
104104
* — the module constructs every outbound path from fixed components so such
105105
* sequences never appear in practice.
106+
*
107+
* This is NOT a general SSRF guard. It performs no DNS resolution, so a
108+
* configured host that resolves (now or later) to a link-local or private
109+
* address passes unchanged. It also does not see what happens after the
110+
* request leaves this check: REDCap core's http_get()/http_post() follow
111+
* HTTP redirects, so a terminology server that replies
112+
* 302 -> http://169.254.169.254/latest/meta-data/ is followed there
113+
* regardless of what this method decided. Redirect-following and DNS
114+
* rebinding are both outside what this check covers.
106115
*/
107116
public static function isWithinBase($url, $baseUri)
108117
{
@@ -137,6 +146,13 @@ public static function isWithinBase($url, $baseUri)
137146
return false;
138147
}
139148
}
149+
// Scheme is already confirmed equal above, but that alone does not make it
150+
// a valid HTTP(S) URL - without this, isWithinBase($baseOverride, $baseOverride)
151+
// (used by validateSettings() as a well-formedness check) would accept
152+
// schemes such as gopher:// or ftp:// as well-formed.
153+
if (!in_array(strtolower($u['scheme']), array('http', 'https'))) {
154+
return false;
155+
}
140156
if (self::port($u) !== self::port($b)) {
141157
return false;
142158
}

FindValueSetService.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@
7777

7878
if ('find' === $action){
7979
$result = $module->findValueSet($type, $query);
80+
if (is_array($result) && array_key_exists('error', $result)) {
81+
// Breaker open, transport failure, or (in practice unreachable, since
82+
// $type is validated above) an unknown search type. Signal it with a
83+
// non-2xx status, the same way getValueSetInfo()'s false return becomes
84+
// a 502 below, instead of a 200 the autocomplete widget would read as a
85+
// genuine "no matches".
86+
http_response_code(502);
87+
}
8088
echo json_encode($result, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
8189
}
8290
else {

tests/run.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
<?php
2+
if (PHP_SAPI !== 'cli') { exit; }
23
/**
34
* Plain-PHP test runner. No composer, no PHPUnit - the module must stay
45
* installable by copying a directory, so tests carry no dependencies.
56
*
7+
* This file lives inside the module directory, which sits under the REDCap
8+
* web root, so it must refuse to execute as an unauthenticated web request -
9+
* hence the SAPI guard above, as the first executable statement.
10+
*
611
* Usage: php tests/run.php
712
*/
813

@@ -165,6 +170,16 @@ function assertFalse($actual, $label)
165170
assertTrue(FhirRequestPolicy::isWithinBase('https://ts.example.org/fhir/x?q=../../etc', $base),
166171
'isWithinBase: traversal in query string allowed');
167172

173+
// --- scheme must be http/https (validateSettings()'s self-check path uses ----
174+
// --- isWithinBase($x, $x), where scheme equality alone lets anything through) --
175+
176+
assertFalse(FhirRequestPolicy::isWithinBase('gopher://internal:70/', 'gopher://internal:70/'),
177+
'isWithinBase: gopher scheme rejected even when url equals base');
178+
assertFalse(FhirRequestPolicy::isWithinBase('ftp://internal/fhir', 'ftp://internal/fhir'),
179+
'isWithinBase: ftp scheme rejected even when url equals base');
180+
assertTrue(FhirRequestPolicy::isWithinBase('http://ts.example.org/fhir', 'http://ts.example.org/fhir'),
181+
'isWithinBase: http scheme still allowed when url equals base');
182+
168183
// --- summary --------------------------------------------------------------
169184

170185
$passed = $GLOBALS['tests_passed'];

0 commit comments

Comments
 (0)