Repository navigation
fix(publish): read the pypi trusted publishing audience from the index - #1374
Merged
Merged
Conversation
Warehouse instances do not share an OIDC audience (TestPyPI expects testpypi), so always requesting `pypi` made the mint-token exchange fail on any index but pypi.org, with an error pointing at the trusted publisher registration. Query `<index>/_/oidc/audience` first, as pypa/gh-action-pypi-publish does, and request the GitHub OIDC token for that audience. Only pypi.org falls back to `pypi` when the lookup fails; any other index reports that it does not support trusted publishing. Closes #1344 Signed-off-by: BryanFRD <bryanferrando59@gmail.com>
BryanFRD
enabled auto-merge (squash)
November 9, 2026 13:32
Contributor
There was a problem hiding this comment.
Approve, with one security fix recommended before merge. Reading the audience from the index's /_/oidc/audience is correct (it fixes TestPyPI, whose audience is testpypi), and the new tests cover all three requests. Formal verification found a credential leak in an error message that this PR makes reachable on a new path.
To fix
- low-medium,
src/publishers/pypi_oidc.rs(index_base/index_audience):index_basekeeps the whole authority of the registry url, so userinfo,?and#carry into the index. With a registry url ofhttps://ci:s3cr3t@127.0.0.1:<port>/simple/, a failed audience lookup returnsE6102: publisher pypi: could not read the trusted publishing audience from https://ci:s3cr3t@127.0.0.1:<port>/_/oidc/audience; ..., so the credential is printed. Reproduced against the checked-out code. The mint-token error had the same leak before this PR, but now any non-pypi.org index whose audience lookup fails reaches it. Twine supports credentials in private index urls. Suggested fix: strip userinfo, query and fragment inindex_base(or refuse them), and never put the raw url in the message. - minor,
src/publishers/pypi_oidc.rs:100(fallback_audience): it only matches the exact indexhttps://pypi.org. A registry url ofhttps://upload.pypi.org/legacy/gives the indexhttps://upload.pypi.org, so a failed lookup is a hard error instead of thepypifallback. This contradicts the docs line saying pypi.org falls back. Either also matchhttps://upload.pypi.orgor narrow the docs wording. Formal verification notes the current behaviour matches "only pypi.org falls back" as documented, so this is a decision rather than a defect.
Formal verification
Bounded exhaustive tests (about 3.2M urls), not proofs.
| Property | Result |
|---|---|
index_base accepts only https:// urls with a non-empty host, returns exactly https://<authority>, never panics |
passed |
pypi audience fallback used only when the index is exactly https://pypi.org |
passed |
| Derived index is a bare origin (no userinfo, query, fragment) | counterexample (see above) |
One harness module (exhaustive, 3 tests) covers these properties; its code is below.
Harness code (appended to src/publishers/pypi_oidc.rs)
#[cfg(test)]
mod exhaustive {
//! Bounded exhaustive checks, not proofs: every url built from one of
//! `PREFIXES` and up to `MAX_SUFFIX` characters of `ALPHABET`.
use super::*;
const PREFIXES: [&str; 6] = ["https://", "http://", "", "HTTPS://", "https:/", "ftp://"];
const ALPHABET: [char; 9] = ['a', '.', '/', ':', '?', '#', '@', 'é', '\\'];
const MAX_SUFFIX: u32 = 6;
fn each_url(mut check: impl FnMut(&str, &str, &str)) {
for prefix in PREFIXES {
for len in 0..=MAX_SUFFIX {
for mut n in 0..ALPHABET.len().pow(len) {
let mut rest = String::new();
for _ in 0..len {
rest.push(ALPHABET[n % ALPHABET.len()]);
n /= ALPHABET.len();
}
check(prefix, &rest, &format!("{prefix}{rest}"));
}
}
}
}
#[test]
fn index_base_keeps_exactly_the_https_authority_and_refuses_the_rest() {
each_url(|_, _, raw| {
let authority = raw
.strip_prefix("https://")
.map(|rest| rest.split('/').next().unwrap());
match index_base(Some(raw)) {
Ok(index) => {
let authority = authority.expect("only https urls are accepted");
assert!(!authority.is_empty(), "{raw}");
assert_eq!(index, format!("https://{authority}"), "{raw}");
assert!(!index["https://".len()..].contains('/'), "{raw}");
}
Err(_) => assert!(authority.is_none_or(str::is_empty), "{raw}"),
}
});
}
#[test]
fn index_base_is_a_bare_origin() {
let mut leaks = Vec::new();
each_url(|_, _, raw| {
if let Ok(index) = index_base(Some(raw)) {
if index["https://".len()..].contains(['?', '#', '@']) {
leaks.push((raw.to_string(), index));
}
}
});
assert!(leaks.is_empty(), "{} leaks, e.g. {:?}", leaks.len(), &leaks[..3.min(leaks.len())]);
}
#[test]
fn only_pypi_org_falls_back_to_the_pypi_audience() {
for raw in ["https://pypi.org", "https://pypi.org/", "https://pypi.org/legacy/"] {
assert_eq!(fallback_audience(&index_base(Some(raw)).unwrap()), Some("pypi"));
}
each_url(|_, _, raw| {
if let Ok(index) = index_base(Some(raw)) {
assert_eq!(fallback_audience(&index).is_some(), index == "https://pypi.org");
}
});
for raw in ["https://upload.pypi.org/legacy/", "https://PyPI.org/legacy/", "https://pypi.org:443/legacy/"] {
assert_eq!(fallback_audience(&index_base(Some(raw)).unwrap()), None, "{raw}");
}
}
}Not verified
- Kani proofs of
index_base: CBMC runs out of memory (8 GB) onanyhow::Errorconstruction, so only the bounded tests above exist. - The
mint_on/index_audienceHTTP flow is covered by the PR'sFakeServertests (passing), not by verification.
ferrflow Bot
added a commit
that referenced
this pull request
Nov 9, 2026
## [7.35.4] - 2026-11-09 ### Bug Fixes - fix(publish): read the pypi trusted publishing audience from the index (#1374)
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 #1344
pypi_oidc::mintalways asked GitHub for an OIDC token withaudience=pypi, so trusted publishing to TestPyPI or another Warehouse index was refused at/_/oidc/mint-token.<index>/_/oidc/audienceand request the OIDC token for the returned audience (ureqquery, so it is encoded and joins an existing query string).pypi; any other index errors naming the audience url, before GitHub is asked for a token.mint_on(index, request_url, token)out ofmintso the whole flow is tested against the local test server: a non-default audience (testpypi) end to end, an index without the endpoint, a refused exchange, a refused/garbled OIDC token.