fix: correct overstated claims, constrain outbound requests, drop dead cache - #5
fix: correct overstated claims, constrain outbound requests, drop dead cache#5drmayu7 wants to merge 30 commits into
Conversation
|
Thanks for this PR. I pointed Claude at it and this is what the adversarial review gave me. -- Thanks for the thorough writeup and the tranching — the no-auth removal and the XSS fix in particular are solid, verified findings. A few things worth resolving before merge: 1. (High) The timeout fix doesn't bound the failure mode it's meant to fix, on any host with curl installed
The scenario in the PR description — "a slow or restarting terminology server could hold PHP workers open" — is a server that accepts the connection and then stalls before sending a response. That's not covered: So 2. (Medium) Circuit breaker's atomicity is weaker than the commit messages describe
Neither is a security issue, and both are still a clear improvement over no breaker — but I'd soften the "exactly one" language in the docs/commit history, or add a lock (e.g. a short-lived unique key insert) if the single-prober property actually matters operationally. 3. (Low) The new The fix keeps the important part — restoring the Confirmed correct, no notes:
-- I do have some work locally on the project to move to framework 16. If you want to address the issues raised above I can merge the PR and try and upgrade the EM in the official registry. If you are interested I can also commit my changes to a branch and we can work on a new release. |
- continue-on-error on the SARIF upload step: GITHUB_TOKEN is forced read-only on pull_request runs triggered from forks regardless of the security-events: write permission declared in this workflow, so the upload always failed on external contributions (this repo has them - see PR #5). That failure was indistinguishable from a real Psalm finding in the job's status. The final step already gates correctly on steps.psalm.outcome alone, so this doesn't lose any signal. - Drop 'develop' from the push trigger - this repo no longer has a permanent develop branch. Found by /code-review on PR #6. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Pilot of the CI tooling discussed for the ontology-provider modules, on this repo first since it's under active review (see #5). ## What this adds - **`security-scan.yml`** — runs Psalm's taint analysis (`--taint-analysis`) on every PR, scoped to mirror the REDCap consortium's stated security-scan categories (SQL, XSS, cookies, headers, path traversal, shell, LDAP, curl/SSRF) rather than general code quality. Findings are also uploaded as SARIF to the Security tab via `github/codeql-action/upload-sarif`, making them eligible for Copilot Autofix assignment. - **`stubs/redcap-em-framework.phpstub`** — minimal method/function *signatures* (no implementation) for the EM framework surface this module calls, with `@psalm-taint-sink`/`@psalm-taint-escape` annotations, so Psalm can do useful taint tracking without needing REDCap core itself in a public CI runner. Sourced from REDCap's published EM Framework docs, not REDCap source. - **`release-please.yml`** — dormant until this branch reaches `main` (it only triggers on pushes there). Uses `release-type: simple` since `config.json` has no version field to bump; release-please owns its own manifest. - **`pr-title-lint.yml`** — enforces Conventional Commits on PR titles via `pull_request_target` (safe here — only reads the title, never checks out fork code), so release-please has something reliable to parse. - **`.github/dependabot.yml`** — weekly version-update PRs for `composer` (Psalm itself) and `github-actions` (the pinned action versions here). ## Verified before opening this PR - Ran Psalm against the actual module code (clean) and against a deliberately tainted throwaway file (correctly flagged `TaintedSql` via the stubbed `query()` sink and `TaintedHtml`/`TaintedTextWithQuotes` via `echo`), to confirm the scan detects real taint rather than silently passing everything. - Confirmed `--report=psalm-results.sarif` produces valid SARIF (schema 2.1.0) alongside the normal console output, not instead of it. ## Known limitation Psalm only analyzes PHP. The module's actual JS lives embedded in PHP heredoc strings, which no mainstream JS static analyzer (ESLint, CodeQL) can see — that's a separate piece of follow-up work (extracting embedded `<script>` blocks into real `.js` files), deliberately not part of this PR. ## Still open - Repo merge-strategy restriction to squash-only (needed for release-please to read a clean one-commit-per-PR history) — pending, not part of this PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
release-please-action's inline `release-type: simple` mode infers the last release purely from git tags matching strict semver (`MAJOR.MINOR.PATCH`). This repo's real release history uses 2-part tags for several releases (`v0.2`, `v0.3`, `v0.4`, `v0.5`), which release-please can't parse — so it silently anchored to the highest tag it *could* parse (`v0.2.3`) and proposed `v0.2.4` as the next release in #12. That's behind the actual latest release (`v0.5`) and would have collided badly once PR #5's v0.5.1/v0.5.2 work lands, had it merged as-is. Switches to manifest mode (`release-please-config.json` + `.release-please-manifest.json`) so the starting version is explicit rather than inferred, seeded at `0.5.0` to match the real `v0.5` tag — meaning the next `fix:`/`feat:` commit correctly proposes `0.5.1`, matching what PR #5's own README already calls its first tranche. #12 (which proposed `v0.2.4`) has been closed rather than left to self-correct, since release-please won't cleanly reconcile an already-proposed higher version against a corrected lower baseline on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for taking the time on this, and for running it through a proper adversarial pass. All three findings are real — I've checked each against the code. Here's what I plan to change. 1. Timeout — agreed, and it's the one that mattersI can't independently confirm the core behaviour: I don't have read access to the REDCap source on my server, so I'm taking your reading of Rather than try to match behaviour I can't read, I'd like to stop calling core for the module's outbound requests entirely: one module-owned curl path setting both I'd apply it to the OAuth2 token endpoint as well — a stalled token endpoint parks a worker just as effectively as a stalled FHIR server. The breaker stays wrapped around the three FHIR entry points rather than the HTTP helpers, so OAuth negotiation against a different host still isn't trapped by it. One question, and it's the thing actually blocking me: what TLS options does core set on those curl handles? If core disables peer verification and I enable it, I break every site whose terminology server presents an internal CA or self-signed certificate — and since these usually sit on internal networks, that could be a lot of them. If I disable it, I've quietly weakened transport security inside a security release. I'd rather not guess, and you can read core where I can't. 2. Breaker atomicity — you're right, and I'd like to fix the claim rather than the codeThe read-then-write races are real. But I think the defect here is the docblock asserting "exactly one concurrent request", not the behaviour itself. The breaker exists to stop a stampede of hundreds of workers dialling a dead server; a handful of probes slipping through at a window boundary is harmless. A lost increment only means it opens on the fourth failure instead of the third, and self-corrects. The options for a real guarantee are a Say the word if you'd rather have the hard guarantee and I'll implement it properly instead. 3. Dead cache — agreed, removing itKeeping On testingFair hit. I've since built a dependency-free PHP harness on a later branch — no composer, no PHPUnit, just It won't prove curl actually times out against a genuinely stalled server, and it won't prove TLS verification doesn't break real deployments. Both need a live environment and I have no staging instance, so I'll be explicit in the PR about where the tests stop rather than implying they cover more than they do. Housekeeping since the PR openedI'll rebase onto current One heads-up: making the curl call visible to Psalm will most likely trip Framework 16Yes, I'm interested. I'd suggest keeping it separate from this PR though. The three fixes above are framework-independent — there's no HTTP helper at any framework version, so the timeout work is identical either way — and bundling a REDCap floor change from 8.8.1 to 14.6.4 into a security diff makes both harder to review. If you push your branch I'll work from it. My REDCap is on 16.0.37, so I can exercise framework 16 directly, though not 17 since that needs 17.0.1. |
Holds the subagent-driven-development ledger, task briefs, and review packages for the in-flight plan. Scratch state, not source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 8d982e665f3f8ac76d235fda806567406f7ac775)
The endpoint had no authorisation check of any kind. Because Snowstorm is self-hosted and internal while REDCap is internet-facing, this made the module a supported route for anonymous users to query an internal-only service and read its responses verbatim, and to trigger unbounded outbound calls at will. The designer builds its URL with getUrl(..., $noAuth = false, ...), so it never requests the no-auth path and is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 613d03acf9c74182841edeb441fa5750b32ac6d2)
Every autocomplete keystroke made a synchronous outbound call with null passed as the timeout, so a slow or unreachable Snowstorm parked a PHP-FPM worker for the system default. Workers are a shared fixed pool, so routine Snowstorm maintenance could take all of REDCap offline. Exposed as a setting rather than a constant so it can be tuned against production without a redeploy - ECL expansion timings on the self-hosted server are not yet known and there is no staging instance to measure on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 3a36fab30c311d87d1b726dea64d35bfa1fa9a12)
Three consecutive failures open the breaker for 60 seconds, during which the FHIR entry points return immediately instead of dialing out. After the window one trial request is allowed through; success resets, failure reopens. This turns a self-hosted Snowstorm restart from a site-wide REDCap outage into degraded autocomplete on one field type. recordFhirSuccess only writes when there is state to clear, so a healthy server costs zero extra writes on the per-keystroke path. Scoped to the three FHIR entry points rather than the shared http helpers so it does not also trap OAuth2 token negotiation, which targets a different host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit d6ebe21f99476630637b06d2564358955ea91213)
getHideChoice read $Proj->metadata without declaring global $Proj, so $Proj was always null in function scope and the intended in-memory path never executed. Every autocomplete keystroke therefore fell through to REDCap::getDataDictionary(), which builds project metadata across several queries - paid even by the majority of projects that never use @HIDECHOICE. Declares the global, adds a per-request cache keyed pid|field, and fixes an undefined-variable read when field is set but pid is absent. The @HIDECHOICE parsing itself is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 136e257041e3075f497d743cecb7472a55978514)
expires_in is seconds per RFC 6749 and time() is seconds, but the value was multiplied by 1000 - caching a 3600s token for roughly 41 days. The module would keep sending a token the IdP had already expired, producing silent 401s with no retry and no surfaced error. The failure is latent for the first hour, so no deployment-window test would have caught it. Fixed now while the code path is still inert under Basic Auth. Also guards json_decode output with is_array before array_key_exists; a false response decodes to null, which is fatal on PHP 8. Token storage stays in \$_SESSION - relocating it to server-wide storage was explicitly deferred to the OAuth2 migration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 62d53dd355772bd001ca4d9017dbb58255d48362)
$list['expansion'] was read before the is_array guard, so every failed request logged a PHP 8 warning. During a terminology server outage every request takes that path, so logs and disk I/O blew up during precisely the incident where that hurts most. code/system/display are also not guaranteed present by FHIR. Separately, an action that was neither find nor info matched no branch and sent no error, falling through to getValueSetInfo with an undefined $valueSet. It now returns 400, and a failed lookup returns 502 with an OperationOutcome the designer dialog already knows how to render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 94ce3ffaf554964c63c55f0d0490d2190b37cc44)
The dialog concatenated FHIR server responses into HTML strings and appended them. Since v0.5 the valueset url is manually editable, so a project designer can store arbitrary text as a field's ontology category; Snowstorm echoes that text back in OperationOutcome.issue.diagnostics, and any later viewer clicking Show Details executed it - including an admin, making this a privilege escalation path. Rows and error cells are now built through the DOM. The adjacent fields that already used .text() are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 389b0bac8376d45b14ecfb23ead244375bb27393)
Records the shipped fixes in the existing per-version changelog style, documents the new timeout setting, and explains the circuit breaker so an admin who sees a minute of dead autocomplete after a terminology server restart knows why. States the deploy-as-new-directory and rollback path explicitly, since there is no non-production instance to stage on, and notes that credential masking is deferred to 0.5.2 because it requires re-entering the credential. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit b779652d4233a3c438d9a785faf655b89b9a974b)
The Basic Auth password and OAuth2 client secret were declared as text settings, so they rendered in cleartext to any admin opening the module config page. They now render masked. IMPORTANT - this does NOT encrypt them at rest. The External Modules documentation states plainly: "Values saved with a password setting are still stored as plain text. It is not encrypted." So the credential remains readable in redcap_external_module_settings and in database backups. This change closes the shoulder-surfing / casual-admin-viewing half of the finding only; the at-rest exposure is unchanged and would need a different mechanism. Shipped as its own tranche (v0.5.2) because it is the only change that can break authentication outright - the stored value does not migrate and must be re-entered after upgrading - and because there is no non-production REDCap available to stage it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 560c7594bc0c377c690343c72bf4a29c72925ce2)
Final whole-branch review findings, one pass: - searchOntology no longer fabricates a storable "No Results Found" entry when the FHIR call failed or the breaker was open (data integrity). - isCircuitOpen() now re-arms the open window before admitting a trial request, so exactly one caller probes the server after an outage instead of a thundering herd at the 60s boundary. - The breaker only counts a failure when the call actually hung (>=80% of the configured timeout), via new recordFhirFailureIfSlow(), so a fast 4xx rejection (e.g. a malformed ECL valueset url) can no longer trip the breaker for every project on the system. - isCircuitOpen() is now checked before getAuthHeader() in all three FHIR entry points, preserving fail-fast-without-dialing-out once OAuth2 token fetch can itself make an outbound call. - getAuthHeader() returns false instead of emitting a malformed "Authorization: Bearer " header when the token fetch fails. - validateSettings()'s OAuth2 test block no longer fatals on PHP 8 when the token endpoint returns non-JSON, and no longer references the undefined $http_response_header. - getHideChoice()'s $Proj fast path now confirms $Proj is the project named by the request before trusting its in-memory metadata. - README corrected: the 0.5.1 section no longer implies masking is a vague future plan; a new 0.5.2 section documents the masking change that already shipped, including that the credential does not migrate and is still stored as plain text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 1e97f4876027a967fe29785695063718b5ab41da)
…ns, correct warning symptom Edit 1: Restore $Proj fast path in getHideChoice() (FhirOntologyAutocompleteExternalModule.php) Separated field-presence check from field_annotation-existence check to avoid falling through to getDataDictionary() call for every un-annotated field on each keystroke. The previous fix was too strict - it checked isset() on field_annotation which is NULL for the common case of fields without annotations. Now checks isset($Proj->metadata[$field]) for the fast path and conditionally accesses the annotation within that path. Edit 2: Reorder changelog sections (README.md) Moved Version 0.5.2 section above Version 0.5.1 to maintain newest-first ordering. Updated reference in 0.5.1 note from "described below" to "described above" to match new position. Edit 3: Correct failure symptom in 0.5.2 warning (README.md) Updated warning to describe actual symptom: a 401 from missing credentials produces an empty dropdown with no error shown to user, not a "No Results Found" entry. The "No Results Found" entry is now only produced when the server genuinely responds with no matches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 8237c4fa71e9e56615aaf45fb54c260b9b005183)
Records the constraints and architecture that are expensive to rediscover: the PHP 5.4 floor that forbids ??, the designer JS living inside a PHP heredoc where a $-sigil variable is silently interpolated away, the empty redcap_every_page_before_render hook that must not be deleted because provider registration happens in the constructor, and the field_annotation NULL trap that silently reinstates a per-keystroke dictionary load. Also records the deliberate boundaries in the resilience layer so they are not 'simplified' later, and that type: password masks the config UI without encrypting at rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit e7ee7f8c86fe93433a10388b402455274c8a56b3)
CLAUDE.md is agent guidance for this working copy, not part of the module. Keep it on disk but out of the tree. Also ignore .env, which held FHIR credentials while untracked and unignored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPXp7y4xQY8xm8mnj5i4qU (cherry picked from commit 20d3dcffa0aad2670460d0d9019778cee1c618fe)
It refers to a file that exists only in the fork's working copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPXp7y4xQY8xm8mnj5i4qU (cherry picked from commit 74e25c0e068bf006e9569cafa124c9b70158a835)
The module had no automated verification, which the upstream review called out. FhirRequestPolicy holds the timeout and circuit-breaker decisions as pure functions so they can be exercised without REDCap. (cherry picked from commit 7886381874ddb63f026d3c716dad040e74b5abca)
isCircuitOpen() documented a guarantee of exactly one trial request per window. The read and write are separate settings round trips with no lock, so concurrent callers can each probe, and concurrent failures can undercount. Behaviour is unchanged; the documentation now matches it. Decisions move to FhirRequestPolicy so they are covered by tests. (cherry picked from commit c4d1e149e02a7ddef8aaa75ea0554f274d4f7844)
getHideChoice() is called once per request, and each autocomplete keystroke is its own PHP process, so the static was written and never read. The global $Proj fast path - the change that actually mattered - is retained. (cherry picked from commit fa18c309376eeb16de7f1f2b4dfed3afce8c7d38)
Constrains every outbound request to the configured FHIR base so the module cannot be pointed at arbitrary hosts on the REDCap server's network. Groundwork for the module-owned curl transport. (cherry picked from commit acd80dd80767a519966b33b873c25ea236d284a3)
Paths containing literal or percent-encoded dot segments (. or .. or %2e, %2E, %2f, %2F) are now rejected in isWithinBase(). Dot segments are decoded iteratively (up to 5 times) to catch double-encoded forms like %252e%252e. This eliminates SSRF bypasses via path traversal while maintaining compatibility with legitimate requests that contain dots within segment names. (cherry picked from commit f6754c3d5d67505627162f7139fa1ea7f73af8b7)
…nd parameters Improves path inspection to catch sophisticated SSRF bypasses: - Decodes all percent escapes iteratively (not just %2e/%2f) to handle double-encoding like %25%32%65%25%32%65 - Normalizes backslashes to forward slashes to prevent backslash-as- separator tricks - Strips path parameters (;-delimited suffixes) before segment testing to defeat Tomcat-style ..;/admin tricks - Rejects any segment consisting entirely of dots (not just . and ..) to block ....// cascading-filter evasions Query strings remain unconstrained — the check only inspects the path component, so legitimate lookups like ?code=1..5 and ?q=../../etc are allowed. (cherry picked from commit 5bfd8254fb5ab3886ea3ff852dc447671cd64a7e)
Correct the docblock to state the bounded guarantee precisely rather than an absolute one. The function constrains dot-segment traversal specifically (in literal, percent-encoded, and obfuscated forms), not all traversal. It also explicitly documents that query strings are not inspected and that encoding techniques outside the module's threat model (like overlong UTF-8) are not blocked. This prevents the implication of an absolute security property that does not exist, maintaining reviewer confidence in security claims. (cherry picked from commit de0ae33c103e0fe057502eae541f82eac9a21335)
release-please now derives version numbers from commit types, so the README no longer names releases. Also corrects the circuit breaker's single-probe claim and the @HIDECHOICE caching claim. (cherry picked from commit f44b322c03ad4975359d7874719b69ff3d08d15a)
The SSRF containment check added earlier was called only from its own tests. Guard httpGet() and httpPost() so outbound requests must target the configured FHIR server (via getFhirServerUri(), which normalizes the trailing slash) or, for httpPost(), the configured OAuth2 token endpoint on an exact match. Both helpers already treat a false return as a failed request, so denying disallowed URLs this way is a no-op change in behaviour for correctly configured installs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit a899fde227d5f9f1e70f495c832c05f71b21d834)
The fhir_timeout setting bounds curl's connect timeout, not its total-time timeout, because REDCap core's http_get()/http_post() helpers never set CURLOPT_TIMEOUT. So the setting protects against an unreachable or refusing host, but a server that accepts the connection and then stalls can still hold a web server process open indefinitely on the curl path. The file_get_contents fallback (curl absent) is the one case with a true end-to-end bound, since its stream context timeout is total-time. Correct this in both the README's "Requests to the FHIR server now time out" entry and the getFhirTimeout() docblock, note the circuit breaker as what currently limits the damage from a stalling server, and flag the module issuing its own curl requests with an explicit CURLOPT_TIMEOUT as planned follow-up work, pending the maintainer's answer on core's TLS options. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit 1f0d17573196033dae6fcc999053062a0eba1c53)
The isWithinBase guards added to httpGet()/httpPost() checked candidate settings passed to validateSettings() (settings a saved-config admin is trying to save) against the already-persisted fhir_api_url and cc_token_endpoint. Editing either setting meant validation's own probe request no longer matched the *old* saved value, so it was rejected and the admin could never save a corrected URL. Add an optional trailing $baseOverride parameter to both methods so a caller that already knows which base a URL should be validated against can say so explicitly. validateSettings()'s two calls now pass the candidate fhir_api_url / cc_token_endpoint as the override. Every other call site builds its URL from the already-persisted settings and is unaffected; verified by inspecting every httpGet()/httpPost() call site in the file. The check itself is not bypassed for validation - it still runs, just against the correct base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit de00ce23bf3daa2044f2e0b026c545966b4e22c7)
The prior narrowing of the timeout claim (README and getFhirTimeout() docblock) traded one overstatement for another: it correctly conceded that a server accepting the connection and never responding can hold a web server process open indefinitely, then in the same breath claimed the circuit breaker catches this because "a hung call trivially exceeds 80% of this timeout... and counts as a failure." That is wrong for the case just conceded. recordFhirFailureIfSlow() only runs after httpGet()/httpPost() returns. A call that genuinely never returns - no CURLOPT_TIMEOUT, server silent forever - is killed by PHP's own execution time limit before that line is reached, so it is never recorded and never trips the breaker. Rewrite both passages to state precisely what the breaker does and does not cover: it helps for the common case of a slow or erroring server that eventually returns (slow response, reset, an OS/proxy-layer timeout), because those calls do return and get counted, and once open it blocks all further calls outright. It does not help against a true indefinite hang, for the reason above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit 28a13139939e586f56ab79a622bffee52ea7770c)
Finding B: isWithinBase() rejected any URL carrying userinfo, full stop. A site that legitimately configures fhir_api_url with embedded credentials (https://user:pass@host/fhir) would therefore have every outbound request rejected, since the module always builds the request URL from that same setting - a 100% silent failure (empty dropdown, no error), which is exactly the failure mode this PR is trying to remove elsewhere. Change the rule to reject userinfo unless it exactly matches the base's own userinfo (user and pass compared separately). A base with no userinfo still rejects any userinfo on the URL, unchanged from before. The host/scheme/port/path checks are untouched and still do the real containment work; this only stops the control from rejecting a URL for carrying the very credentials the administrator configured. Added assertions for: base without userinfo + URL with userinfo (already covered, relabelled for clarity), base with userinfo + same userinfo on the URL, base with userinfo + different userinfo, and base with userinfo + no userinfo on the URL. 59 assertions, up from 56. Finding C: at the validateSettings() token-endpoint call, $authEndpoint is passed as both the URL and its own $baseOverride, so isWithinBase(x, x) is trivially true there. That is intentional - it still validates well-formedness on a value an admin just typed - but a future caller could mistake it for a containment boundary. Documented this both at that call site and in the $baseOverride comments in httpGet()/httpPost(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit 7d07f5748bd6ac0634db43cbaabc33877adca2b0)
The maintainer's PR aehrc#5 review found the timeout and SSRF corrections made elsewhere were still contradicted in other places, and one claim was simply false about this branch's own diff: - README and config.json both still described the FHIR timeout setting as bounding the whole response, in the admin-facing config.json text as well as a second README mention 70 lines below the already-corrected one. Both now say it bounds connection time only. - FhirRequestPolicy::isWithinBase()'s docblock claimed the check prevents the module being used as a proxy for arbitrary hosts. It only validates the URL this module constructs; REDCap core's http_get()/http_post() follow redirects and no DNS resolution is performed, so the docblock now says so explicitly. - The README claimed credential masking "is not included in this set of changes" - false, both password-type conversions are in this branch's config.json. Removed that claim along with unrelated single-site deploy/ rollback instructions, while keeping the silent-failure credential non-migration warning (now naming both the Basic Auth password and the OAuth2 client secret, not just the former). - Added a README bullet documenting the new outbound URL containment check, and qualified the circuit breaker bullet to note it only counts slow failures, not fast 4xx responses. - Dropped .superpowers from .gitignore (submitter-local tooling with no upstream meaning) and ignore it locally instead via .superpowers/.gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVZekhAz2pittp3iGgsKnK (cherry picked from commit 912f71c0cee23197d2fe668901de6e1dfdb41d4a)
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)
668547d to
5892174
Compare
Security and performance work carried out on a fork, offered back upstream. Rebased onto current
main, and updated to address the review.What changed since the review
All three findings were real. Two are fixed; the third is deferred with the claim narrowed, per the fallback suggested in the review.
1. Timeout — claim narrowed, real fix deferred
I could not confirm core's behaviour independently: I have no read access to the REDCap source on my server, so I took the reading of
Config/init_functions.phpon trust. Assuming it holds,fhir_timeoutbounds only connection establishment, not a server that accepts and then stalls.Rather than ship a fix built on a guess, the claim is now scoped to what is true — in the changelog, in the settings reference, in the
getFhirTimeout()docblock, and in the module configuration screen the administrator actually reads while setting the value. The circuit breaker's coverage is also stated honestly: it helps when a slow or erroring server eventually returns, since those calls are recorded, but it does not help against a true indefinite hang, because the breaker is only informed after the call returns.The open question, which is the one thing blocking the real fix: what TLS options does core set on its curl handles? If core disables peer verification and I enable it, I break every site whose terminology server presents an internal CA — the common deployment for this module. If I disable it when core verifies, I have quietly weakened transport security inside a security release. Once that is answered, the module can issue its own curl requests with an explicit
CURLOPT_TIMEOUT.2. Circuit breaker — claim corrected, no locking added
The read-then-write races are real. The defect was the docblock asserting "exactly one concurrent request", not the behaviour: the breaker exists to stop a stampede, and a handful of probes slipping through at a window boundary is harmless. A real guarantee would cost a
GET_LOCK()round trip on every autocomplete keystroke, or SQL againstredcap_external_module_settings. Neither seemed worth it, so the documentation now describes what the code does — normally one probe, occasionally a few, and a counter that can undercount under concurrency so the breaker may open after slightly more than three failures.Happy to implement the guarantee instead if you would rather have it.
3. Dead cache — removed
Gone, along with the comment claiming a per-keystroke benefit it could not deliver.
global $Projand the metadata fast path — the part that actually mattered — are retained.Also in this update
FhirRequestPolicy::isWithinBase()checks scheme, host and port, requires the path to sit at or below the configured base, rejects mismatched embedded credentials, and rejects dot-segment traversal in literal, percent-encoded, double-encoded, backslash-separated and;-parameter forms. Rejections are logged (with credentials stripped) rather than failing silently. The docblock is explicit that this is not a general SSRF guard: it validates the URL this module constructs, and does not cover redirect-following by core's helpers or DNS-based rebinding.php tests/run.php, 62 assertions. The timeout resolution, every circuit-breaker state transition, and the URL containment rules are covered.tests/run.phprefuses to run under a web SAPI.findValueSet()no longer returns an empty list on failure. It now returns the existing['error' => ...]shape and the service replies 502, matchinggetValueSetInfo(), so an outage is not rendered as "no matches".What is verified, and what is not
php -lis clean on all files,config.jsonparses,php tests/run.phppasses, and Psalm's taint analysis reports no findings.php-version-minstays5.4.0andframework-versionstays1.The tests cover pure decision logic only. They do not prove that anything times out against a genuinely stalled server, and they cannot — that needs a live environment and I have no staging instance. Runtime behaviour was exercised by hand against an Ontoserver-backed endpoint. An independent look is still worth having before merging.
Framework 16
Interested, and I would suggest keeping it separate from this PR. The changes here are framework-independent, and bundling a REDCap floor change from 8.8.1 to 14.6.4 into a security diff makes both harder to review. If you push your branch I will work from it — my REDCap is on 16.0.37, so I can exercise framework 16 directly.