mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-10 13:58:24 +01:00
security: fail closed on gRPC TLS errors, verify dialed server identity (#12263)
* security: fail closed on misconfigured gRPC TLS LoadServerTLS/LoadClientTLS returned plaintext credentials on every error path once TLS was configured: a typo in tls.min_version or tls.cipher_suites, cert and key in different directories, or cert/key set without grpc.ca all left the component serving or dialing plaintext. A partially configured TLS setup now stops the process at startup instead; leaving cert/key unset still means no TLS, as before. The loaders also stat cert/key/ca files up front since the pemfile providers read lazily and would otherwise only fail at handshake time. Mirrored in the Rust volume server: partial grpc.volume cert/key, a missing grpc.ca, or a rustls build failure now abort startup rather than warn-and-continue on plaintext. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * security: optional server-identity allowlist for gRPC clients The mTLS client accepts any certificate the configured CA signs, so a per-workload CA lets one component stand in for any other: a filer identity can answer as the master. The server side already screens callers via allowed_commonNames and allowed_wildcard_domain; nothing screened the server. LoadClientTLS now wires AdditionalPeerVerification when the caller configures a server allowlist: grpc.<component>.allowed_server_commonNames (falling back to a shared grpc.allowed_server_commonNames) matched against the peer cert's subject CN, DNS SANs, or URI SANs such as a SPIFFE id, plus a grpc.allowed_server_wildcard_domain suffix match. Leaving them unset keeps the previous behavior. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * security: URI SANs are exact-match only in the server identity allowlist A URI identity whose path happens to end in the wildcard domain would otherwise pass the DNS suffix check. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * security: tighten gRPC TLS fail-closed and server-identity paths - URI SANs match the server-identity allowlist exactly; the wildcard domain suffix check applies only to CN and DNS SANs. - A partially set client_cert/client_key pair is an error when the cert/key fallback is empty, instead of dialing plaintext. - Cert/key/ca files are loaded and parsed at startup (LoadX509KeyPair and CA PEM validation) since pemfile providers read lazily. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * rust volume: error on a partial grpc client_cert/client_key pair Mirror the Go loadClientTLS path: when client_cert or client_key is set without its mate and the cert/key fallback is empty, fail instead of dialing plaintext. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * security: wildcard domain match requires a valid hostname A CA-signed identity like ".example.com" satisfied the bare suffix check for wildcard ".example.com" despite not being a hostname. Require a real subdomain: non-empty label before the suffix, a '.' boundary when the configured wildcard has no leading dot, and DNS-name syntax. A leading "*." label still matches so wildcard certs keep working. Applied to both the Go server/client authenticators and the Rust volume's client-cert verifier. * security: ignore a trailing root dot in wildcard domain matching * tls: reject a partial dedicated client cert/key pair instead of falling back When exactly one of component.client_cert/client_key is set, warn-and- fallback silently dialed with the serving identity. Fail closed like the other TLS misconfigurations in this change; mirrored in the Rust loader. Regression tests cover a partial pair with a valid fallback. * rust volume: harden partial client cert pair test with real pem files * security: verify dialed server identity per target kind on the filer The filer reused one grpc.filer-verifying dial option for master, volume, filer and s3 targets, so a broad allowlist accepted the wrong component at the wrong port and a narrow one rejected legitimate peers. Outbound calls now pick grpc.<target>.allowed_server_commonNames (falling back to the client component's and the global list) while still presenting the grpc.filer client identity. * security: scope per-target server allowlists under the client component The filer read grpc.volume.allowed_server_commonNames when dialing a volume, but that key already denotes the volume's own outbound allowlist (e.g. the master identity). A deployment restricting volume->master calls would make the filer reject real volume servers. Per-target policy now lives under the client component: grpc.filer.volume_* falls back to grpc.filer.* then grpc.*. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
7d8b72c2b4
commit
e98adfd3bb
15 files changed
+821
-101
No files matched your search
@@ -363,8 +363,51 @@ fn common_name_is_allowed(
|
||||
allowed_common_names: &HashSet<String>,
|
||||
allowed_wildcard_domain: &str,
|
||||
) -> bool {
|
||||
(!allowed_wildcard_domain.is_empty() && common_name.ends_with(allowed_wildcard_domain))
|
||||
|| allowed_common_names.contains(common_name)
|
||||
allowed_common_names.contains(common_name)
|
||||
|| wildcard_domain_matches(common_name, allowed_wildcard_domain)
|
||||
}
|
||||
|
||||
// Mirrors the Go wildcard check: the name must end with the domain, carry a
|
||||
// non-empty label in front of it, and be a valid DNS name so a CA-signed name
|
||||
// like ".example.com" cannot satisfy the suffix check. A leading "*." label
|
||||
// is allowed so wildcard certs keep matching. One trailing root dot is
|
||||
// ignored on both sides so absolute DNS names still match.
|
||||
fn wildcard_domain_matches(name: &str, wildcard_domain: &str) -> bool {
|
||||
let name = name.strip_suffix('.').unwrap_or(name);
|
||||
let wildcard_domain = wildcard_domain.strip_suffix('.').unwrap_or(wildcard_domain);
|
||||
if wildcard_domain.is_empty()
|
||||
|| name.len() <= wildcard_domain.len()
|
||||
|| !name.ends_with(wildcard_domain)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !wildcard_domain.starts_with('.')
|
||||
&& name.as_bytes()[name.len() - wildcard_domain.len() - 1] != b'.'
|
||||
{
|
||||
return false;
|
||||
}
|
||||
is_domain_name(name.strip_prefix("*.").unwrap_or(name))
|
||||
}
|
||||
|
||||
// Mirrors Go's net.IsDomainName: non-empty labels of letters, digits, '-',
|
||||
// '_', each up to 63 chars and not '-'-edged, with at least one non-digit so
|
||||
// a bare IP cannot pass a domain wildcard.
|
||||
fn is_domain_name(name: &str) -> bool {
|
||||
if name.is_empty() || name.len() > 253 {
|
||||
return false;
|
||||
}
|
||||
let mut non_numeric = false;
|
||||
let valid = name.split('.').all(|label| {
|
||||
non_numeric |= label.bytes().any(|c| !c.is_ascii_digit());
|
||||
!label.is_empty()
|
||||
&& label.len() <= 63
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||||
});
|
||||
valid && non_numeric
|
||||
}
|
||||
|
||||
fn go_tls_version_for_supported(version: &SupportedProtocolVersion) -> GoTlsVersion {
|
||||
@@ -436,10 +479,50 @@ mod tests {
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(common_name_is_allowed(
|
||||
"node.example.com.",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(common_name_is_allowed(
|
||||
"node.example.com.",
|
||||
&allowed_common_names,
|
||||
".example.com.",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
"node.example.com..",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
"node..example.com.",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
"node.prod.other.net",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
".example.com",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
"node..example.com",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
assert!(!common_name_is_allowed(
|
||||
"badexample.com",
|
||||
&allowed_common_names,
|
||||
"example.com",
|
||||
));
|
||||
assert!(common_name_is_allowed(
|
||||
"*.example.com",
|
||||
&allowed_common_names,
|
||||
".example.com",
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user