Horizon: now · Effort: S · security hardening
A publish_url carrying userinfo (https://user:password@host/) leaks the credential into user-visible output, publicly served files and signed credentials. The only guard that exists today covers one narrow path, and that guard leaks the password itself.
1. did_web_from_url echoes the password it exists to reject
ob3/did.py:289-296. The docstring says the userinfo check is there so a user:pass@ credential does not reach the DID embedded in every issued credential — but the checks run in the wrong order:
if parts.scheme != 'https':
raise ValueError('did:web requires an https URL, got %r' % (url,)) # raw url
if parts.username or parts.password:
raise ValueError('did:web URL must not contain userinfo (user:pass@)')
if not parts.hostname:
raise ValueError('URL %r has no host' % (url,)) # raw url
http://user:password@host/ therefore prints the password in the error — which the CLI writes to stdout and into the error field of --json. The rejection publishes the credential.
2. The wider hole: the guard almost never runs
did_web_from_url is reached only when the issuer DID is derived ([issuer] did = auto, confparser.py:94 → ob3/publish.py:360). With an explicit did = did:web:..., or when publishing OB1/OB2, nothing validates publish_url and it flows unsanitized into:
- stdout on the success paths of publish —
openbadges_publish.py:394-395 (OB3), 546-547 (OB2), 602-603 (OB1), 407 (--check-live);
- files meant to be served publicly —
openbadges_publish.py:484-522 builds ids with urljoin(publish_url, ...) (urljoin preserves user:pass@) and writes them into organization.json, badge_*/badge.json and key.json;
- signed OB3 credentials delivered to the recipient —
confparser.py:189-193 derives status_base/list URLs from publish_url, and issue.py:496 puts them in credentialStatus.statusListCredential.
The same applies to the other public identifiers in those sections ([issuer] url — the OB3 issuer-id fallback — image, and a badge's criteria / status_base / hosted_assertions_base).
Action
Reject userinfo once, at config-parse time in confparser.py, so every consumer (OB1/OB2/OB3 publishing, DID derivation, status lists, published files) inherits the guarantee instead of re-checking it. The ConfigError must name the section and key and must not echo the value. [smtp] username/password stay untouched — they never leave the issuer's machine.
Keep the did.py check as defence in depth, but fix its ordering and stop interpolating the raw URL in the other two messages.
Tests: a config with https://user:pass@host/ must be rejected, and no error message anywhere may contain the password.
Horizon: now · Effort: S · security hardening
A
publish_urlcarrying userinfo (https://user:password@host/) leaks the credential into user-visible output, publicly served files and signed credentials. The only guard that exists today covers one narrow path, and that guard leaks the password itself.1.
did_web_from_urlechoes the password it exists to rejectob3/did.py:289-296. The docstring says the userinfo check is there so auser:pass@credential does not reach the DID embedded in every issued credential — but the checks run in the wrong order:http://user:password@host/therefore prints the password in the error — which the CLI writes to stdout and into theerrorfield of--json. The rejection publishes the credential.2. The wider hole: the guard almost never runs
did_web_from_urlis reached only when the issuer DID is derived ([issuer] did = auto,confparser.py:94→ob3/publish.py:360). With an explicitdid = did:web:..., or when publishing OB1/OB2, nothing validatespublish_urland it flows unsanitized into:openbadges_publish.py:394-395(OB3),546-547(OB2),602-603(OB1),407(--check-live);openbadges_publish.py:484-522builds ids withurljoin(publish_url, ...)(urljoin preservesuser:pass@) and writes them intoorganization.json,badge_*/badge.jsonandkey.json;confparser.py:189-193derivesstatus_base/list URLs frompublish_url, andissue.py:496puts them incredentialStatus.statusListCredential.The same applies to the other public identifiers in those sections (
[issuer] url— the OB3 issuer-id fallback —image, and a badge'scriteria/status_base/hosted_assertions_base).Action
Reject userinfo once, at config-parse time in
confparser.py, so every consumer (OB1/OB2/OB3 publishing, DID derivation, status lists, published files) inherits the guarantee instead of re-checking it. TheConfigErrormust name the section and key and must not echo the value.[smtp] username/passwordstay untouched — they never leave the issuer's machine.Keep the
did.pycheck as defence in depth, but fix its ordering and stop interpolating the raw URL in the other two messages.Tests: a config with
https://user:pass@host/must be rejected, and no error message anywhere may contain the password.