Skip to content

security(config): reject publish_url credentials before they are published - #254

Merged
luisgf merged 1 commit into
masterfrom
autofix/beautiful-mcnulty-f13643
Jul 22, 2026
Merged

security(config): reject publish_url credentials before they are published#254
luisgf merged 1 commit into
masterfrom
autofix/beautiful-mcnulty-f13643

Conversation

@luisgf

@luisgf luisgf commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Closes #252.

A publish_url carrying 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:

  • Not just 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's criteria / 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. Its username/password are legitimate secrets that never leave the issuer's machine.

The ConfigError names the offending section and key but never the value.

did.py as defence in depth

Userinfo 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.example and the Changelog under * v4.0.0 - unreleased.

Tests

flake8 clean, mypy clean (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] password still loads; no message carries the password (neither str() nor repr()); and an end-to-end case where publish -V 1/2/3 exits 1, writes no output tree and prints not one byte of the password.

Residual, on purpose

An integrator that hand-builds a ConfigParser and passes it to issue() bypasses read_conf() entirely. reject_url_userinfo() is therefore exported (no underscore) so that caller can apply the same check.

…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
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.

publish_url userinfo leaks into stdout, published files and signed credentials

1 participant