test(security): GHSA-named regression tests for 3p3x and r5qv (backlog#1151 sec-6) (#4707)

test(security): add GHSA-named regression tests for 3p3x and r5qv (backlog#1151 sec-6)

Anchor the two fixed advisories to discoverable, named regression tests so
`rg -i "ghsa|3p3x|r5qv"` finds a guard for each, and future fixes are forced
to update pinned behavior (red -> green).

GHSA-3p3x-734c-h5vx (constant-time WebDAV/FTPS secret comparison, rustfs#4403):
- ftps_core.rs: new `assert_ftps_ghsa_3p3x_wrong_credentials_rejected` drives
  the `ct_eq` reject branch in FtpsAuthenticator::authenticate; asserts wrong
  password and unknown user are both rejected (530) and indistinguishable.
- webdav_core.rs: the auth-failure block now sends a valid access key with a
  wrong secret (exercising the `ct_eq` branch, not just the unknown-access-key
  path) plus an unknown user, asserting both 401 and indistinguishable.
- Module doc comments map advisory -> tests -> fix PR on both files.

GHSA-r5qv-rc46-hv8q (internode RPC fail-closed, rustfs#4402):
- http_auth.rs: renamed the default-fallback rejection test to
  `ghsa_r5qv_resolve_shared_secret_rejects_default_fallback` (and broadened it
  to cover default env secret + blank secrets), and added
  `ghsa_r5qv_verify_rpc_signature_fails_closed_on_missing_or_invalid_auth`
  pinning the exact advisory scenario (missing/forged/cross-URL signature is
  rejected; a correctly signed request still passes). File-level doc maps the
  advisory.

Docs: new docs/testing/security-regressions.md with the advisory -> test
mapping table and where each layer runs; linked from docs/testing/README.md.
sec-14 will formalize the written policy in AGENTS.md.

The unit-level ghsa_r5qv_* tests run in the default CI pass. The WebDAV/FTPS
e2e live in the protocols suite (fixed ports, --test-threads=1); they cannot
join the e2e-smoke profile and are wired into CI by sec-5.

Refs: rustfs/backlog#1151 (sec-6), rustfs/backlog#1155
This commit is contained in:
Zhengchao An
2026-07-11 13:18:27 +08:00
committed by GitHub
parent 4b83efaf36
commit 846aa95c32
5 changed files with 270 additions and 24 deletions
+71 -1
View File
@@ -12,6 +12,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Internode RPC HMAC authentication.
//!
//! # Security regression coverage
//!
//! GHSA-r5qv-rc46-hv8q (internode RPC authentication must fail closed, fixed in
//! rustfs/rustfs#4402) is anchored by the `ghsa_r5qv_*` tests in the module
//! below, plus the broader negative-signature suite. The advisory class is: a
//! node must never accept an RPC whose auth is missing, malformed, replayed, or
//! signed with the default/empty shared secret. See
//! `docs/testing/security-regressions.md` for the full advisory -> test map.
//!
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
use crate::cluster::rpc::context_propagation::{inject_request_id_into_http_headers, inject_trace_context_into_http_headers};
use base64::Engine as _;
use base64::engine::general_purpose;
@@ -227,13 +240,70 @@ mod tests {
runtime_sources::ensure_test_rpc_secret();
}
/// Security regression for GHSA-r5qv-rc46-hv8q (internode RPC fail-closed,
/// fixed in rustfs/rustfs#4402): secret resolution must never silently fall
/// back to a default/empty shared secret. Missing and default secrets both
/// resolve to an error, so a misconfigured node cannot come up with a
/// predictable, attacker-known RPC key.
#[test]
fn test_resolve_shared_secret_rejects_default_fallback() {
fn ghsa_r5qv_resolve_shared_secret_rejects_default_fallback() {
let err = resolve_shared_secret(None, None).expect_err("default fallback must be rejected");
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
let err = resolve_shared_secret(None, Some(DEFAULT_SECRET_KEY)).expect_err("default global secret must be rejected");
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
let err = resolve_shared_secret(Some(DEFAULT_SECRET_KEY), None).expect_err("default env secret must be rejected");
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
let err = resolve_shared_secret(Some(" "), Some(" ")).expect_err("blank secrets must be rejected");
assert_eq!(err.to_string(), RPC_SECRET_REQUIRED_MESSAGE);
}
/// Security regression for GHSA-r5qv-rc46-hv8q: `verify_rpc_signature` must
/// fail closed for every shape of missing or malformed authentication.
/// Consolidates the advisory's exact scenario (no valid signature/timestamp
/// pair => rejected, never silently allowed) into one named test so the
/// advisory maps to a discoverable regression.
#[test]
fn ghsa_r5qv_verify_rpc_signature_fails_closed_on_missing_or_invalid_auth() {
ensure_test_rpc_secret();
let url = "http://example.com/api/test";
let method = Method::GET;
// No auth headers at all.
let empty = HeaderMap::new();
assert!(
verify_rpc_signature(url, &method, &empty).is_err(),
"request with no auth headers must be rejected"
);
// Signature header present but garbage; timestamp is current.
let mut forged = HeaderMap::new();
let now = OffsetDateTime::now_utc().unix_timestamp();
forged.insert(SIGNATURE_HEADER, HeaderValue::from_static("not-a-real-signature"));
forged.insert(TIMESTAMP_HEADER, HeaderValue::from_str(&now.to_string()).unwrap());
assert!(
verify_rpc_signature(url, &method, &forged).is_err(),
"request with a forged signature must be rejected"
);
// A validly signed request for a *different* URL must not authorize this one.
let mut cross = HeaderMap::new();
build_auth_headers("http://example.com/api/other", &method, &mut cross).expect("auth headers should build");
assert!(
verify_rpc_signature(url, &method, &cross).is_err(),
"a signature bound to a different URL must not authorize this request"
);
// Control: a correctly signed request for this URL still succeeds, so the
// gate is fail-closed rather than fail-everything.
let mut valid = HeaderMap::new();
build_auth_headers(url, &method, &mut valid).expect("auth headers should build");
assert!(
verify_rpc_signature(url, &method, &valid).is_ok(),
"a correctly signed request must be accepted"
);
}
#[test]