Repository navigation
zenodo: send the API token as a Bearer header and verify the bucket host - #139
rocklambros wants to merge 2 commits into
Conversation
The deposit script passed the token as an `access_token` query parameter on every request. Query strings are recorded by access logs, egress proxies and Referer headers, so the credential outlived the request in places nobody audits. Zenodo accepts `Authorization: Bearer`, which keeps it in a header. The bundle upload also targeted `draft["links"]["bucket"]`, a URL taken straight from the API response, and attached the token to it without checking where it pointed. The API base is hardcoded, so this rested on trusting Zenodo to name its own host rather than on an open redirect, but a `deposit:write` credential should not follow an unverified URL. `upload_url()` now compares the bucket host against the API host and fails closed, resolving the target before the bundle is opened so a rejected host stops the run with nothing sent. Both matter more once the deposit runs from CI against a repository secret, which is the direction documentation/ZENODO.md lays out. Tests cover the Bearer header, the absence of the token from any query string, and a bucket URL on a foreign host. The stub now returns a bucket on the same host as the API base, matching what Zenodo does. Closes #138
|
@stefanoamorelli tagging you since this is your script from #124 and it changes a test you wrote. Two things worth your judgment rather than a rubber stamp:
No rush on this one. Nothing runs the script automatically, so it is a hardening pass ahead of any CI wiring rather than a live exposure. |
stefanoamorelli
left a comment
There was a problem hiding this comment.
thanks @rocklambros! both changes LGTM.
- you can keep the equality check. Zenodo's docs return the bucket on the API host, and our record (22109015) has every file link on
zenodo.orgwith no redirects. example.invalidwas reserved-domain hygiene.
Two things to fix, inline comments:
- Token assertions run after run 1 only, so the concept branch is unasserted. (I reverted the
records/{concept}GET toparams=and the suite still printedall checks passed.) upload_urlchecks the host but not the scheme, sohttp://zenodo.org/api/files/...sends the token cleartext.
nits:
- line 154's comment no longer holds, the bucket is inside
oursnow check("refuses to upload", refused)passes on anySystemExit
| # The token belongs in a header. Query strings reach access logs, egress proxies | ||
| # and Referer headers, all of which outlive the request. | ||
| check( | ||
| "token sent as an Authorization Bearer header", | ||
| all((k.get("headers") or {}).get("Authorization") == "Bearer fake-token" for _, _, k in CALLS), | ||
| ) | ||
| check( | ||
| "token never reaches a query string", | ||
| not any( | ||
| "access_token" in u or "access_token" in (k.get("params") or {}) | ||
| for _, u, k in CALLS | ||
| ), | ||
| ) |
There was a problem hiding this comment.
These two only run against run 1, and CALLS resets at the top of every run(). Run 1 passes concept=None, so the records/{concept} GET, the newversion POST, the draft GET and the inherited-file DELETE never get asserted, which is 4 of the 8 call sites you moved onto headers=. I reverted the concept GET to params= and the full suite still printed all checks passed.
Hoisting into a helper covers it:
| # The token belongs in a header. Query strings reach access logs, egress proxies | |
| # and Referer headers, all of which outlive the request. | |
| check( | |
| "token sent as an Authorization Bearer header", | |
| all((k.get("headers") or {}).get("Authorization") == "Bearer fake-token" for _, _, k in CALLS), | |
| ) | |
| check( | |
| "token never reaches a query string", | |
| not any( | |
| "access_token" in u or "access_token" in (k.get("params") or {}) | |
| for _, u, k in CALLS | |
| ), | |
| ) | |
| # The token belongs in a header. Query strings reach access logs, egress proxies | |
| # and Referer headers, all of which outlive the request. | |
| def check_token(label: str) -> None: | |
| check( | |
| f"token sent as an Authorization Bearer header ({label})", | |
| all((k.get("headers") or {}).get("Authorization") == "Bearer fake-token" for _, _, k in CALLS), | |
| ) | |
| check( | |
| f"token never reaches a query string ({label})", | |
| not any( | |
| "access_token" in u or "access_token" in (k.get("params") or {}) | |
| for _, u, k in CALLS | |
| ), | |
| ) | |
| check_token("first deposit") |
then check_token("new version") after line 200. I ran it, suite stays green and that same revert now fails both.
There was a problem hiding this comment.
Hoisted into check_token(label), called after both runs. 69f2937
Confirmed your repro before and after: reverting the concept GET to params= now fails both assertions on the new-version run, where it previously printed all checks passed.
FAIL token sent as an Authorization Bearer header (new version)
FAIL token never reaches a query string (new version)
| bucket = (draft.get("links") or {}).get("bucket") | ||
| if not bucket: | ||
| sys.exit("error: Zenodo returned no bucket link for the draft") | ||
| expected = urlparse(base).netloc |
There was a problem hiding this comment.
netloc only, so http://zenodo.org/api/files/bucket-1 passes and the token goes out cleartext. The redirect stripping doesn't help here because the script builds the URL itself, no redirect involved. A separate check keeps the two failure modes legible, which matters now that ZENODO.md documents the exact error text:
| expected = urlparse(base).netloc | |
| if urlparse(bucket).scheme != "https": | |
| sys.exit(f"error: bucket URL is not https: {bucket!r}") | |
| expected = urlparse(base).netloc |
Everything else I threw at it refused correctly, including userinfo (https://zenodo.org@evil.invalid), protocol-relative, explicit :443, trailing dot and subdomains.
There was a problem hiding this comment.
Added as a separate exit ahead of the host comparison, as suggested. 69f2937
Confirmed failing first. With the check absent, a bucket on http://zenodo.org/api uploaded the bundle and published, token attached:
bucket URL on the API host over http
uploaded t.zip (2205823 bytes)
version DOI: 10.5281/zenodo.222
FAIL refuses on a plaintext bucket URL no SystemExit
FAIL sends the token over no cleartext URL
FAIL does not publish over http
Keeping the two exits separate earns its keep in the docs: ZENODO.md now carries both strings and says to widen the host comparison for one and report the other, since there is no version of a cleartext bucket worth allowing.
Review on #139 found two assertions that looked like coverage and were not. upload_url() compared netloc alone, so a bucket on the API host over http passed the check and the Bearer token went out in cleartext. The script builds that URL itself, so redirect stripping never applied. Scheme and host now exit separately, because widening the host comparison is the right response to one and never the right response to the other. The two token assertions ran after the first deposit only, and CALLS resets at the top of every run(). The concept path contributes four of the eight call sites, and none of them were asserted: reverting the concept record GET to params= left the suite green. Hoisted into check_token(), called after both runs. Also: the foreign-host case accepted any SystemExit, so it passed when the run died for an unrelated reason, and the comment above the API base check no longer described what the check did once the stub moved the bucket onto the API host. Each assertion was confirmed failing against the code it guards. 31 checks, --check passes, semgrep p/python, p/security-audit and p/secrets report 0 findings on .github/scripts/.
|
@stefanoamorelli all four addressed in 69f2937. Both fixes and both nits, with replies in the threads. Nits
One thing I added beyond the review The scheme check had no test of its own, which is the same failure your review is about, so the http case got its own run. That is what produced the RED output in the thread above. Kept as-is Equality check stays, on your read of the docs and record 22109015. Verification 31 assertions, all passing. Each new one confirmed failing against the code it guards, including your Still wired to nothing, so no live exposure either way. Back to you when you have a moment. |
Closes #138. Follow-up to #124.
What changed
The token now goes out as
Authorization: Bearer.deposit()previously builtparams = {"access_token": token}and passed it to all six request calls. Query strings get copied into access logs, egress proxies andRefererheaders, so the credential outlived the request in places nobody audits.The bucket host is verified before the token is attached. The upload targeted
draft["links"]["bucket"], a URL taken from the API response. The API base is hardcoded, so this rested on trusting Zenodo to name its own host rather than on an open redirect, but a token carryingdeposit:writeanddeposit:actionsshould not follow a URL the script never checked.upload_url()compares the bucket host against the API host and fails closed.Ordering matters here: the target resolves before
bundle.open(), so a rejected host ends the run with nothing sent and no file handle opened.Neither is urgent while the script runs by hand from a laptop. Both matter once the deposit runs from CI against a repository secret, which is the direction
documentation/ZENODO.mdsets out.Tests
Three assertions were added, and each was confirmed failing against the old code before the fix went in:
token sent as an Authorization Bearer headerparams=token never reaches a query stringaccess_tokenon any callrefuses to upload/sends nothing to the foreign host/does not publishThe stub previously returned a bucket on
example.invalidwhile the API base waszenodo.org. That no longer reflects Zenodo, which serves the bucket from the API host, sodraft_payload()takes abucket_baseand the happy-path runs pass the matching host. The foreign-host case now has its own run.Full suite: 26 assertions, all passing.
--checkpasses. semgrepp/python,p/security-audit,p/secretson.github/scripts/: 0 findings.One tradeoff worth naming
The host check fails closed. If Zenodo ever moves file storage to a separate host, the deposit stops rather than uploading. That is the safer default for a credential, but it is a release-blocking failure mode, so
documentation/ZENODO.mdnow shows the exact error and says what to do about it: confirm the new host really is Zenodo's, then widen the comparison inupload_url().Not changed
The script is still wired to nothing. No workflow runs it, and no Zenodo token belongs in repository secrets until record ownership is settled, per the reasoning in #124.