security(config): reject publish_url credentials before they are published - #254
Merged
Conversation
…ished
A [issuer]/[badge_*] URL carrying userinfo (https://user:password@host/) is
now rejected when the config is loaded. Those URLs are public identifiers:
publish_url is printed by every publish command, urljoin'ed into the ids
written to the publicly served organization.json / badge.json / key.json
(urljoin preserves the user:pass@), turned into the issuer's did:web, and
used to build the credentialStatus list URLs signed into every OB3
credential — so a password there leaked into user-visible output, files
served on the open web and the badges delivered to recipients alike.
Only one narrow path guarded against it: did_web_from_url, reached solely by
[issuer] did = auto. And that guard ran its checks in the wrong order — the
scheme check quoted the raw URL before the userinfo check was reached, so
http://user:password@host/ echoed the password into the CLI error (and into
the 'error' field of --json), publishing the credential via the very error
meant to reject it.
The check now lives once in ConfParser.read_conf(), after ${...}
interpolation so a reference cannot smuggle a credential past it, and every
consumer (OB1/OB2/OB3 publishing, DID derivation, status lists) inherits it.
The ConfigError names the offending section and key but never the value.
did_web_from_url keeps its own guard as defence in depth, with the userinfo
check moved ahead of the scheme and host checks and what those quote
redacted, so the guarantee no longer depends on the ordering.
[smtp] username/password are untouched — they never leave the issuer's
machine. Behaviour change: a config that protected a staging host with
credentials in a published URL no longer loads.
Tests assert the rejection across [issuer]/[badge_*] keys, through an
interpolated reference, and end to end (publish -V 1/2/3 exits 1, writes no
output tree, and prints no byte of the password); no error message anywhere
carries it.
Closes #252
This was referenced Jul 22, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #252.
A
publish_urlcarrying userinfo (https://user:password@host/) leaked the credential into user-visible output, publicly served files and signed credentials. The only guard that existed ran on one narrow path — and echoed the password itself.The layer chosen
The check now lives once in
ConfParser.read_conf(), the single funnel every consumer passes through (load_config/read_config_or_exit, hence all five CLIs). OB1/OB2/OB3 publishing, DID derivation, status lists and the published files all inherit the guarantee instead of re-checking it. It runs after${...}interpolation, so a reference to an unchecked section cannot smuggle a credential past it.Two decisions beyond the minimum:
publish_url, but every value in[issuer]and[badge_*]. The same hole exists identically in[issuer] url(the OB3 issuer-id fallback),image, and a badge'scriteria/status_base/hosted_assertions_base— all end up in publicly served JSON. It is the same code (a section sweep instead of one key) and closes the whole class.[smtp]is deliberately excluded. Itsusername/passwordare legitimate secrets that never leave the issuer's machine.The
ConfigErrornames the offending section and key but never the value.did.pyas defence in depthUserinfo is checked first, ahead of the scheme and host checks, and what those two quote is redacted (
netloc.rpartition('@')[2]) — so the guarantee no longer depends on the ordering surviving a future edit.Behaviour change
A config that protected a staging host with credentials in a published URL no longer loads. That is the intent: the URL was being published. Documented in
wiki/Configuration.md,config.ini.exampleand the Changelog under* v4.0.0 - unreleased.Tests
flake8clean,mypyclean (44 files), 1308 pass. The new tests are not vacuous — with the fix reverted, 18 fail.Coverage: rejection per
[issuer]/[badge_*]key; all four userinfo shapes (user:pass@,user@,@, non-https scheme); the interpolated route;[smtp] passwordstill loads; no message carries the password (neitherstr()norrepr()); and an end-to-end case wherepublish -V 1/2/3exits 1, writes no output tree and prints not one byte of the password.Residual, on purpose
An integrator that hand-builds a
ConfigParserand passes it toissue()bypassesread_conf()entirely.reject_url_userinfo()is therefore exported (no underscore) so that caller can apply the same check.