Skip to content

fix: correct overstated claims, constrain outbound requests, drop dead cache - #5

Open
drmayu7 wants to merge 30 commits into
aehrc:mainfrom
drmayu7:upstream-v0.5.2
Open

fix: correct overstated claims, constrain outbound requests, drop dead cache#5
drmayu7 wants to merge 30 commits into
aehrc:mainfrom
drmayu7:upstream-v0.5.2

Conversation

@drmayu7

@drmayu7 drmayu7 commented Sep 2, 2026

Copy link
Copy Markdown

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.php on trust. Assuming it holds, fhir_timeout bounds 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 against redcap_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 $Proj and the metadata fast path — the part that actually mattered — are retained.

Also in this update

  • Outbound FHIR requests are now constrained to the configured server. 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.
  • A test suite. You noted there was no test framework, so there is now a dependency-free runner — no composer, no PHPUnit, php tests/run.php, 62 assertions. The timeout resolution, every circuit-breaker state transition, and the URL containment rules are covered. tests/run.php refuses 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, matching getValueSetInfo(), so an outage is not rendered as "no matches".
  • Changelog no longer asserts version numbers, since release-please now derives them. Site-specific deployment prose has been removed. The credential non-migration warning is kept and now names both the Basic Auth password and the OAuth2 client secret — changing a setting's type does not migrate the stored value, and the failure is silent.

What is verified, and what is not

php -l is clean on all files, config.json parses, php tests/run.php passes, and Psalm's taint analysis reports no findings. php-version-min stays 5.4.0 and framework-version stays 1.

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.

@dconlan

dconlan commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@drmayu7

Thanks for this PR.

I pointed Claude at it and this is what the adversarial review gave me.

--
Adversarial review of this PR (static/code review — not run against a live FHIR server)

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

httpGet/httpPost delegate entirely to REDCap core's http_get()/http_post() (Config/init_functions.php). I checked those directly: when curl is available (the default on essentially every production install), the only timeout curl ever gets is CURLOPT_CONNECTTIMEOUT — core never sets
CURLOPT_TIMEOUT. CONNECTTIMEOUT bounds just the TCP handshake.

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: curl_exec() can still block indefinitely once connected, parking the worker exactly
as before this change. Only the file_get_contents fallback (used when curl is absent) gets a true end-to-end timeout, since the stream context timeout option is total-time, not connect-time.

So fhir_timeout genuinely helps against an unreachable host (refused/dropped connection), but not against a slow one — which is the more likely real failure mode for a terminology server under load, and the case actually named in the PR. Suggest either fixing this at the
http_get/http_post call site (there's no way to pass CURLOPT_TIMEOUT through core's helpers as written, so this may need the module to make its own curl calls for the FHIR paths), or scoping the README/PR claim down to "protects against unreachable hosts" until it's addressed.

2. (Medium) Circuit breaker's atomicity is weaker than the commit messages describe

isCircuitOpen() and recordFhirFailure() do read-then-write over getSystemSetting/setSystemSetting, which round-trip the DB with no locking. Two consequences:

  • The "exactly one trial request is allowed through" property (the re-arm in isCircuitOpen()) isn't guaranteed — concurrent requests arriving at the window boundary can each read the window as expired before either writes the re-arm, so more than one trial request can go out.
  • Consecutive-failure counting can lose increments under concurrent failures (classic read-N/write-N+1 race), so the breaker can take longer than 3 actual failures to open.

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 getHideChoice() cache doesn't do anything

The fix keeps the important part — restoring the global $Proj fast path is a real, verified perf win. But the added static $cache keyed pid|field is commented as caching "per keystroke"; OntologyManager::searchOntology() makes one call per HTTP request, and each keystroke is its own PHP
process, so the static cache resets every time and is only ever read once per request. Harmless, but it can be dropped — it's dead weight.

Confirmed correct, no notes:

  • No-auth removal on FindValueSetService.php — verified the Online Designer builds its URL with getUrl(..., $noAuth=false, ...), so it's the only caller and is unaffected.
  • XSS fix — grepped the file for every .append(/.html(/innerHTML call; the two patched spots were the only insertion points, both now go through .text()/createTextNode.
  • expires_in seconds-vs-ms fix, the PHP 8 array_key_exists(null, …) guards, the unknown-action 400, and getAuthHeader()'s false-propagation all match the real failure contracts of the functions they touch.
  • No PHP >5.4 syntax introduced — the php-version-min: 5.4.0 claim holds against this diff.

--

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.

dconlan added a commit that referenced this pull request Sep 3, 2026
- 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>
dconlan added a commit that referenced this pull request Sep 3, 2026
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>
dconlan added a commit that referenced this pull request Sep 3, 2026
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>
@drmayu7

drmayu7 commented Sep 4, 2026

Copy link
Copy Markdown
Author

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 matters

I 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 Config/init_functions.php on trust. Assuming it holds, delegating to core can't be salvaged — there's no way to pass CURLOPT_TIMEOUT through those helpers as written.

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 CURLOPT_CONNECTTIMEOUT and CURLOPT_TIMEOUT, keeping the existing file_get_contents fallback for hosts without curl — that path already gets a true end-to-end timeout, since the stream context option is total-time. Proxy handling mirrors what the module's own fallback already does with sameHostUrl() / PROXY_HOSTNAME / PROXY_USERNAME_PASSWORD, so it doesn't need core either. It also lets the Content-type workaround in httpPost go away.

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 code

The 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 GET_LOCK() round-trip on the hot path of every autocomplete keystroke, or SQL written directly against redcap_external_module_settings, which is framework-internal. Neither looks worth it for a property that doesn't seem to matter operationally. So I'll rewrite the docblock and the PR description to describe what the code actually does — normally one probe, occasionally a few, and failure counting that can undercount under concurrency.

Say the word if you'd rather have the hard guarantee and I'll implement it properly instead.

3. Dead cache — agreed, removing it

Keeping global $Proj and the $Proj->metadata[$field] gating, which is the part that actually mattered, and dropping the comment that claims a per-keystroke benefit it can't deliver.

On testing

Fair hit. I've since built a dependency-free PHP harness on a later branch — no composer, no PHPUnit, just php tests/run.php — and I'll port it here to cover the breaker decisions, timeout resolution and URL validation. That means extracting the pure logic out of the settings I/O so it's reachable without REDCap.

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 opened

I'll rebase onto current main — it's conflicting now — retitle with a conventional-commit prefix for the title lint, and drop the "v0.5.1 / v0.5.2" framing, since release-please owns version numbers now and 0.5.1 is already spoken for. The credential-masking caveat survives as a deployment note rather than a version claim: the stored password doesn't migrate when the setting type changes, so it has to be re-entered immediately after upgrading or lookups fail silently with an empty dropdown.

One heads-up: making the curl call visible to Psalm will most likely trip TaintedSSRF, since at the moment the sink is hidden inside core where the scanner can't see it. I'd rather answer that with a helper constraining the URL to the configured FHIR base than with a taint-escape annotation — a real constraint rather than a suppression.

Framework 16

Yes, 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.

drmayu7 and others added 23 commits September 4, 2026 20:46
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)
drmayu7 and others added 7 commits September 4, 2026 20:46
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)
@drmayu7 drmayu7 changed the title Security and performance remediation (v0.5.1 / v0.5.2) fix: correct overstated claims, constrain outbound requests, drop dead cache Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants