test(security): pin GHSA-m77q STS root-secret token signing (#4823)

test(security): pin GHSA-m77q STS root-secret token signing (sec-7)

GHSA-m77q-r63m-pj89 (intentionally UNFIXED) is that STS session tokens are
signed with the shared root secret: crates/iam/src/root_credentials.rs
token_signing_key() returns the root secret_key, so anyone holding the root
secret can forge STS session tokens. No test named the advisory, and the
existing test_created_sts_credentials_authorize_with_session_token_claims uses
token_signing_key() for both signing and verifying, so it pins "same key signs
and verifies" but not the m77q-specific "signing key IS the root secret" — a
future fix that decouples the STS key from the root secret would pass it
silently.

Add a flow-level pin, test_ghsa_m77q_sts_session_token_signed_with_root_secret,
that captures the advisory's exact signature: (1) token_signing_key() == the
root secret; (2) an AssumeRole-style session token issued with that key decodes
with the root secret and NOT with any other secret; (3) it authorizes through
the STS path. All three assert CURRENT (by-design-vulnerable) behavior, so a
real m77q fix (a dedicated STS signing key) turns them red and forces a
red -> green regression update.

Also GHSA-name the existing characterization test and token_signing_key() with
doc comments and the advisory URL, and update the m77q row in
docs/testing/security-regressions.md. No production behavior changes.

Refs: backlog#1151 (sec-7)

Co-authored-by: houseme <housemecn@gmail.com>
This commit is contained in:
Zhengchao An
2026-07-15 11:43:56 +08:00
committed by GitHub
parent eb392f24d6
commit 468dcaef69
3 changed files with 111 additions and 1 deletions
+9
View File
@@ -22,6 +22,15 @@ pub(crate) fn credentials_or_default() -> Credentials {
credentials().unwrap_or_default()
}
/// The signing key for STS session tokens.
///
/// GHSA-m77q-r63m-pj89 (intentionally UNFIXED): this returns the root secret
/// key, so STS JWTs are signed with the shared root secret and anyone holding
/// it can forge session tokens. The behavior is pinned by
/// `test_ghsa_m77q_sts_session_token_signed_with_root_secret` in `sys.rs`;
/// fixing the advisory (a dedicated STS signing key distinct from the root
/// secret) must update that test red -> green. See
/// docs/testing/security-regressions.md.
pub(crate) fn token_signing_key() -> Option<String> {
credentials().map(|cred| cred.secret_key)
}
+101
View File
@@ -1952,6 +1952,18 @@ mod tests {
);
}
/// GHSA-m77q-r63m-pj89 (STS JWTs signed with the shared root secret —
/// intentionally UNFIXED). This test characterizes the STS credential
/// lifecycle: a session token signed with the active token-signing key
/// decodes and authorizes through the STS path. It pins that the SAME key
/// both signs and verifies STS tokens.
///
/// MUST UPDATE WHEN GHSA-m77q IS FIXED: the fix introduces a dedicated STS
/// signing key distinct from the root secret. The narrower pin that captures
/// the advisory's exact signature (signing key == root secret) is
/// `test_ghsa_m77q_sts_session_token_signed_with_root_secret`; keep both in
/// lockstep and flip them red -> green together.
/// Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-m77q-r63m-pj89>
#[tokio::test]
async fn test_created_sts_credentials_authorize_with_session_token_claims() {
ensure_test_global_credentials();
@@ -2053,6 +2065,95 @@ mod tests {
);
}
/// GHSA-m77q-r63m-pj89 flow-level pin (STS session tokens are signed with the
/// shared root secret — intentionally UNFIXED). Anyone holding the root secret
/// can forge STS session tokens because RustFS reuses the root secret as the
/// STS token-signing key. This test PINS that vulnerable-by-design behavior end
/// to end: (1) the token-signing key IS the root secret, (2) an AssumeRole-style
/// session token issued with that key decodes with the ROOT secret and NOT with
/// any other secret, and (3) it authorizes through the STS path.
///
/// MUST UPDATE WHEN GHSA-m77q IS FIXED: the fix introduces a dedicated STS
/// signing key distinct from the root secret. That makes assertion (1) and the
/// "root secret decodes the token" assertion fail (red). Whoever fixes m77q must
/// replace this pin with a regression asserting the NEW behavior — the root
/// secret can no longer decode STS session tokens — i.e. red -> green.
/// Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-m77q-r63m-pj89>
#[tokio::test]
async fn test_ghsa_m77q_sts_session_token_signed_with_root_secret() {
ensure_test_global_credentials();
let root = crate::root_credentials::credentials().expect("global action (root) credentials should be initialized");
assert!(!root.secret_key.is_empty(), "root secret must be present for the STS signing-key pin");
// (1) m77q signature: the STS token-signing key IS the root secret. A fix
// that introduces a dedicated STS key makes this fail -> update red->green.
assert_eq!(
crate::root_credentials::token_signing_key().as_deref(),
Some(root.secret_key.as_str()),
"GHSA-m77q pin: STS tokens are signed with the root secret. If this fails, \
m77q may have been fixed (dedicated STS key) — update this test red->green."
);
// AssumeRole-style issuance: mint a session token with the active signing key.
// Use the mock store's registered STS parent (group `testgroup` -> readwrite)
// so authorization resolves through the STS path; the m77q pin is about the
// signing key, not the parent identity.
let parent_user = "sts-fallback-test-parent";
let signing_key = crate::root_credentials::token_signing_key().expect("STS token-signing key should be present");
let mut claims = HashMap::new();
claims.insert("parent".to_string(), Value::String(parent_user.to_string()));
claims.insert(
"exp".to_string(),
Value::Number(serde_json::Number::from(
(OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp(),
)),
);
let mut cred = get_new_credentials_with_metadata(&claims, &signing_key).expect("STS credentials should be created");
cred.parent_user = parent_user.to_string();
// (2) The session token decodes with the ROOT secret specifically — the crux
// of m77q — and rejects any non-root secret (HMAC verification fails).
let decoded = get_claims_from_token_with_secret(&cred.session_token, &root.secret_key)
.expect("GHSA-m77q pin: STS session token must decode with the ROOT secret");
assert_eq!(decoded.get("parent").and_then(Value::as_str), Some(parent_user));
assert!(
get_claims_from_token_with_secret(&cred.session_token, "not-the-root-secret").is_err(),
"GHSA-m77q pin: STS session token must NOT decode with a non-root secret"
);
// (3) The root-secret-signed STS credentials authorize end to end.
let store = StsTestMockStore::new(false);
let cache_manager = IamCache::new(store).await.unwrap();
let iam_sys = IamSys::new(cache_manager);
iam_sys
.set_temp_user(&cred.access_key, &cred, None)
.await
.expect("STS credentials should be persisted in the temp-user cache");
let groups: Option<Vec<String>> = None;
let args = Args {
account: &cred.access_key,
groups: &groups,
action: Action::S3Action(S3Action::ListBucketAction),
bucket: "mybucket",
conditions: &HashMap::new(),
is_owner: false,
object: "",
claims: &decoded,
deny_only: false,
};
let prepared = iam_sys.prepare_auth(&args).await;
assert!(
matches!(prepared.mode, PreparedIamMode::Sts { .. }),
"root-secret-signed STS credentials must use the STS authorization path"
);
assert!(
iam_sys.eval_prepared(&prepared, &args).await,
"root-secret-signed STS credentials should authorize through the parent group policy (m77q)"
);
}
/// Regression test: temp credentials without groups in args still receive group-attached
/// policies via the parent user (groups fallback). Without the fallback, policy_db_get
/// would get None for groups and the user would have no group policies, so the action
+1 -1
View File
@@ -20,7 +20,7 @@ forced to update its pinned test (red -> green).
| --- | --- | --- | --- | --- |
| [GHSA-3p3x-734c-h5vx](https://github.com/rustfs/rustfs/security/advisories/GHSA-3p3x-734c-h5vx) | Constant-time secret comparison on WebDAV/FTPS password login | rustfs/rustfs#4403 | `assert_ftps_ghsa_3p3x_wrong_credentials_rejected` (`crates/e2e_test/src/protocols/ftps_core.rs`); `GHSA-3p3x` auth-failure block in `test_webdav_core_operations` (`crates/e2e_test/src/protocols/webdav_core.rs`) | e2e (protocols suite) |
| [GHSA-r5qv-rc46-hv8q](https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q) | Internode RPC authentication must fail closed | rustfs/rustfs#4402 | `ghsa_r5qv_resolve_shared_secret_rejects_default_fallback`, `ghsa_r5qv_verify_rpc_signature_fails_closed_on_missing_or_invalid_auth` (`crates/ecstore/src/cluster/rpc/http_auth.rs`) | unit |
| [GHSA-m77q-r63m-pj89](https://github.com/rustfs/rustfs/security/advisories/GHSA-m77q-r63m-pj89) | STS JWTs signed with shared root secret (intentionally unfixed) | n/a | `test_created_sts_credentials_authorize_with_session_token_claims` (`crates/iam/src/sys.rs`) — pins current behavior; sec-7 adds GHSA naming | unit |
| [GHSA-m77q-r63m-pj89](https://github.com/rustfs/rustfs/security/advisories/GHSA-m77q-r63m-pj89) | STS JWTs signed with shared root secret (intentionally unfixed) | n/a | `test_ghsa_m77q_sts_session_token_signed_with_root_secret` (flow-level pin: signing key == root secret, root-only decode, authorizes) and `test_created_sts_credentials_authorize_with_session_token_claims` (`crates/iam/src/sys.rs`); `token_signing_key` doc (`crates/iam/src/root_credentials.rs`) — pin current by-design behavior; fixing m77q must update red -> green | unit |
## Where these run (CI-execution map)