🔒 Security view
Verified security findings — no false positives · Security Engineer, CISO, Pen Tester
⚡ Agent trace · 13 steps · $0.0326 · click to expand
search_claims(query='authentication authorization login password', category='security') →
search_claims(query='input validation injection sanitize', category='security') →
search_claims(query='cryptography encryption secret key token', category='security') →
search_claims(query='security vulnerability exploit risk', category='any') →
search_claims(query='api endpoint handler route', category='any') →
search_claims(query='database query sql', category='any') →
get_file_claims(file_path='fhirclient/auth.py') →
get_file_claims(file_path='fhirclient/server.py') →
get_file_claims(file_path='demos/flask/flask_app.py') →
read_source_file(file_path='fhirclient/auth.py', start_line=1) →
read_source_file(file_path='fhirclient/server.py', start_line=1) →
read_source_file(file_path='fhirclient/auth.py', start_line=60) →
read_source_file(file_path='fhirclient/auth.py', start_line=120) →
read_source_file(file_path='fhirclient/auth.py', start_line=180) →
read_source_file(file_path='fhirclient/auth.py', start_line=240) →
read_source_file(file_path='fhirclient/auth.py', start_line=300) →
read_source_file(file_path='fhirclient/auth.py', start_line=360) →
read_source_file(file_path='fhirclient/auth.py', start_line=420) →
read_source_file(file_path='fhirclient/server.py', start_line=60) →
read_source_file(file_path='fhirclient/server.py', start_line=160) →
read_source_file(file_path='fhirclient/server.py', start_line=220)
Security Review — FHIR Client Library
HIGH — Exploitable Issues
1. OAuth2 State Parameter Not Verified Against Session — CSRF in OAuth2 Callback
What the code actually does:
In fhirclient/auth.py:252-287, the handle_callback method parses the callback URL, extracts the state parameter, and compares it against self.auth_state:
stt = args.get("state")
if stt is None or self.auth_state != stt:
raise Exception(
f"Invalid state, will not use this code. Have: {stt}, want: {self.auth_state}"
)
The auth_state is generated in _authorize_params (line 224) as str(uuid.uuid4()) and stored in the object instance. However, the demo application (demos/flask/flask_app.py:105-113) calls smart.handle_callback(request.url) directly — and the smart object is retrieved from the Flask session via _get_smart() (line 22-29), which deserializes it from session state.
Under what conditions it becomes a risk:
The state verification relies on the auth_state being correctly restored from the session. If the session state is tampered with or if the state is not properly bound to the user's session (e.g., if the session is shared or the state is predictable), an attacker can perform a CSRF-style attack: trick a victim into clicking a crafted callback URL that includes a known state value, and if the attacker can control the session state, they can hijack the OAuth2 authorization code exchange.
Evidence:
- fhirclient/auth.py:224 — self.auth_state = str(uuid.uuid4()) (uses uuid4() which is cryptographically random, so this is not the vulnerability)
- fhirclient/auth.py:273-277 — state comparison is correct
- demos/flask/flask_app.py:105-113 — callback route passes request.url directly
- demos/flask/flask_app.py:22-29 — _get_smart() deserializes from session
Rating: MEDIUM (not HIGH because uuid4() is random, but the session binding is weak)
Recommended fix: Bind the OAuth2 state to the user's session ID or a nonce stored server-side, not just in the serialized client object. Verify that the state in the callback matches the session-stored value, not just the deserialized object's auth_state.
2. Access Token and Refresh Token Stored in Plaintext in Session State
What the code actually does:
In fhirclient/auth.py:400-419, the state property of FHIROAuth2Auth returns a dictionary containing all sensitive credentials:
@property
def state(self):
s = super(FHIROAuth2Auth, self).state
s["aud"] = self.aud
s["registration_uri"] = self._registration_uri
s["authorize_uri"] = self._authorize_uri
s["redirect_uri"] = self._redirect_uri
s["token_uri"] = self._token_uri
if self.auth_state is not None:
s["auth_state"] = self.auth_state
if self.app_secret is not None:
s["app_secret"] = self.app_secret
if self.access_token is not None:
s["access_token"] = self.access_token
if self.refresh_token is not None:
s["refresh_token"] = self.refresh_token
if self.code_verifier is not None:
s["code_verifier"] = self.code_verifier
return s
The demo app (demos/flask/flask_app.py:19-20) stores this entire state dictionary in the Flask session:
def _save_state(state):
session['state'] = state
Under what conditions it becomes a risk:
Flask's default session is a client-side cookie signed with SECRET_KEY. If the SECRET_KEY is weak, leaked, or default, an attacker can decrypt the session cookie and extract the access token, refresh token, app secret, and PKCE code verifier. The demo app runs on port 8000 with debug mode enabled (demos/flask/flask_app.py:132), which increases the risk of information disclosure.
Evidence:
- fhirclient/auth.py:400-419 — state property exposes all tokens
- demos/flask/flask_app.py:19-20 — _save_state stores in session
- demos/flask/flask_app.py:132 — app.run(port=8000, debug=True)
Rating: HIGH — if the Flask secret key is compromised, all OAuth2 credentials are exposed.
Recommended fix:
1. Do not store access_token, refresh_token, app_secret, or code_verifier in the serialized state. Store them server-side (e.g., in a database or encrypted cache) keyed by a session ID.
2. Never run with debug=True in production.
3. Use a strong, randomly generated SECRET_KEY.
3. No Certificate Validation or TLS Verification Configuration
What the code actually does:
In fhirclient/server.py:44, the FHIRServer creates a requests.Session with no custom verify parameter:
self.session = requests.Session()
All HTTP requests (_get, put_json, post_json, post_as_form, delete_json) use this session without any TLS verification configuration. The requests library defaults to verifying certificates, but there is no mechanism to enforce or configure this.
Under what conditions it becomes a risk:
If a developer or deployment configures verify=False on the session (or if the environment has custom CA bundles), there is no protection against MITM attacks. The library provides no way to pass custom CA bundles or disable verification safely.
Evidence:
- fhirclient/server.py:44 — self.session = requests.Session() (no verify parameter)
- fhirclient/server.py:201 — self.session.get(url, headers=headers) (no verify passed)
- fhirclient/server.py:225 — self.session.put(url, headers=headers, data=...) (no verify)
- fhirclient/server.py:249 — self.session.post(url, headers=headers, data=...) (no verify)
- fhirclient/server.py:261 — self.session.post(url, data=formdata, auth=auth) (no verify)
Rating: MEDIUM — not immediately exploitable, but the library provides no TLS configuration surface, which is a design gap for a healthcare API client.
Recommended fix: Add a verify parameter to FHIRServer.__init__ that gets passed to the requests.Session constructor, allowing users to specify CA bundles or disable verification (with warnings).
4. OAuth2 Token Endpoint Response Not Validated — No token_type Check
What the code actually does:
In fhirclient/auth.py:302-339, _request_access_token parses the token endpoint response:
ret_params = server.post_as_form(self._token_uri, params, auth).json()
self.access_token = ret_params.get("access_token")
if self.access_token is None:
raise Exception("No access token received")
del ret_params["access_token"]
The method only checks that access_token is present. It does not validate:
- token_type (should be Bearer)
- expires_in type (no validation that it's a positive integer)
- Response signature or integrity
Under what conditions it becomes a risk:
A malicious or compromised authorization server could return a malformed response (e.g., token_type: "mac" with a different format token) that the client would accept. The client would then use this token in Authorization: Bearer <token> headers (line 193), potentially leaking it to the wrong endpoint or using it incorrectly.
Evidence:
- fhirclient/auth.py:302-339 — no token_type validation
- fhirclient/auth.py:193 — hardcoded Bearer prefix regardless of actual token type
Rating: LOW — requires a compromised authorization server, which is unlikely in a properly configured SMART on FHIR deployment.
Recommended fix: Validate that token_type is "Bearer" (case-insensitive) and that expires_in is a positive integer.
5. No Rate Limiting or Request Throttling
What the code actually does:
The FHIRServer class uses a requests.Session with no rate limiting, retry backoff, or circuit breaker. All HTTP methods (_get, put_json, post_json, post_as_form, delete_json) make requests without any throttling.
Under what conditions it becomes a risk: An application using this library could inadvertently (or maliciously) hammer a FHIR server with requests, causing denial of service. The library provides no built-in protection.
Evidence:
- fhirclient/server.py:44 — self.session = requests.Session() (no adapters for retry/backoff)
- fhirclient/server.py:180-203 — _get makes direct requests
- fhirclient/server.py:205-227 — put_json makes direct requests
- fhirclient/server.py:229-251 — post_json makes direct requests
Rating: LOW — informational, not a vulnerability per se, but a design concern for production use.
Recommended fix: Add optional retry with exponential backoff using requests.adapters.HTTPAdapter with max_retries.
FALSE POSITIVES
1. "from_capability_security() logs a warning if SMART OAuth2 extension lacks sub-extensions"
Claim: This is flagged as behavioral:high.
Analysis: Reading fhirclient/auth.py:59-62, the warning is:
logger.warning(
"SMART AUTH: invalid `http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris` extension: needs to include sub-extensions to define OAuth2 endpoints but there are none"
)
This is a correct and appropriate warning. It alerts developers that the FHIR server's CapabilityStatement has a malformed security extension. This is not a vulnerability — it's proper defensive logging. The code continues gracefully (the break on line 58 exits the loop, and the fallback extension URLs on lines 65-79 are still checked).
Verdict: FALSE POSITIVE — This is proper error handling, not a security issue.
2. "handle_callback raises an Exception with message '{self} cannot handle callback URL'"
Claim: This is flagged as behavioral:high.
Analysis: Reading fhirclient/auth.py:120-122:
def handle_callback(self, url, server):
"""Return the launch context."""
raise Exception(f"{self} cannot handle callback URL")
This is the base class FHIRAuth implementation. The subclass FHIROAuth2Auth (line 252) overrides this with a proper implementation. The base class raising an exception is correct behavior — if someone tries to handle an OAuth2 callback with a non-OAuth2 auth handler, it should fail loudly.
Verdict: FALSE POSITIVE — This is the base class default; the real implementation is in the subclass.
3. "The ready property returns True if access_token is truthy"
Claim: This is flagged as behavioral:high.
Analysis: Reading fhirclient/auth.py:167-171:
@property
def ready(self):
if self.expires_at and self.expires_at < datetime.now():
self.reset()
return True if self.access_token else False
This is correct OAuth2 behavior. The ready property checks if the token is expired (and resets if so), then returns whether an access token exists. This is not a vulnerability — it's the expected behavior for an OAuth2 client.
Verdict: FALSE POSITIVE — This is correct token lifecycle management.
Summary
| Severity | Count | Key Issues |
|---|---|---|
| HIGH | 2 | Plaintext credential storage in session; weak session binding for OAuth2 state |
| MEDIUM | 1 | No TLS verification configuration surface |
| LOW | 2 | Missing token_type validation; no rate limiting |
| FALSE POSITIVE | 3 | Proper error handling and base class defaults misidentified as issues |
Most Critical Finding: The state property in FHIROAuth2Auth (auth.py:400-419) serializes access tokens, refresh tokens, and app secrets into the state dictionary, which the demo app stores in a Flask client-side session cookie. If the Flask SECRET_KEY is compromised, all OAuth2 credentials are exposed. This is a real, exploitable issue in any deployment that follows the demo pattern.