checker: add domain length validation and refactor rules into per-concern checks
This commit is contained in:
parent
df0d429150
commit
946ec446d2
15 changed files with 716 additions and 308 deletions
|
|
@ -24,10 +24,21 @@ const (
|
|||
tlsNS = "urn:ietf:params:xml:ns:xmpp-tls"
|
||||
)
|
||||
|
||||
// tlsProbeConfig returns a deliberately permissive TLS config for probing.
|
||||
//
|
||||
// InsecureSkipVerify is intentional: certificate chain and hostname validation
|
||||
// is the TLS checker's responsibility. This checker only observes which TLS
|
||||
// versions and cipher suites a server accepts, then hands the endpoints to
|
||||
// checker-tls for the actual certificate audit.
|
||||
//
|
||||
// MinVersion is set to TLS 1.0 so we can observe whether a server still
|
||||
// accepts deprecated protocol versions: that is exactly what we want to
|
||||
// report. A strict client config would prevent us from reaching those servers
|
||||
// at all.
|
||||
func tlsProbeConfig(serverName string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true, //nolint:gosec: cert validation is the TLS checker's job
|
||||
InsecureSkipVerify: true, //nolint:gosec
|
||||
MinVersion: tls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +50,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an
|
|||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain is required")
|
||||
}
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mode, _ := sdk.GetOption[string](opts, "mode")
|
||||
if mode == "" {
|
||||
|
|
@ -106,7 +120,9 @@ func (p *xmppProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (an
|
|||
probeSet(ctx, data, domain, ModeServer, "_xmpps-server._tcp", data.SRV.ServerSecure, true, perEndpoint)
|
||||
|
||||
computeCoverage(data)
|
||||
data.Issues = deriveIssues(data, wantC2S, wantS2S)
|
||||
|
||||
// Collect intentionally does not populate data.Issues; judging the raw
|
||||
// payload is the job of the CheckRules (see rules.go).
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
|
@ -395,6 +411,31 @@ func expectProceed(dec *xml.Decoder) error {
|
|||
}
|
||||
}
|
||||
|
||||
// validateDomain enforces RFC 1123 hostname rules before the value is used in
|
||||
// DNS lookups and embedded in the XMPP stream header.
|
||||
func validateDomain(domain string) error {
|
||||
if len(domain) > 253 {
|
||||
return fmt.Errorf("domain name too long (max 253 characters, got %d)", len(domain))
|
||||
}
|
||||
for _, label := range strings.Split(domain, ".") {
|
||||
if len(label) == 0 {
|
||||
return fmt.Errorf("domain contains an empty label")
|
||||
}
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("domain label %q exceeds 63 characters", label)
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("domain label %q must not start or end with a hyphen", label)
|
||||
}
|
||||
for _, c := range label {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
|
||||
return fmt.Errorf("domain label %q contains invalid character %q", label, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
|
||||
name := prefix + dns.Fqdn(domain)
|
||||
_, records, err := r.LookupSRV(ctx, "", "", name)
|
||||
|
|
@ -439,6 +480,9 @@ func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
|
|||
}
|
||||
}
|
||||
|
||||
// computeCoverage walks raw endpoints and fills in the ReachabilitySpan
|
||||
// aggregate. It is still part of Collect because coverage is a raw summary
|
||||
// of what was actually reached, not a judgment (it has no severity).
|
||||
func computeCoverage(data *XMPPData) {
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
|
|
@ -453,212 +497,15 @@ func computeCoverage(data *XMPPData) {
|
|||
}
|
||||
switch ep.Mode {
|
||||
case ModeClient:
|
||||
// We consider c2s working if SASL was advertised, OR if STARTTLS
|
||||
// c2s is reachable if SASL was advertised OR if STARTTLS
|
||||
// completed but features couldn't be read (benign for probes).
|
||||
if len(ep.SASLMechanisms) > 0 || !ep.FeaturesRead {
|
||||
data.Coverage.WorkingC2S = true
|
||||
}
|
||||
case ModeServer:
|
||||
// Similarly, s2s is "working" if TLS completed. A misconfigured
|
||||
// server that advertised TLS but no dialback/EXTERNAL is reported
|
||||
// via the xmpp.s2s.no_auth issue, not via coverage.
|
||||
// s2s reachable if TLS completed; the dialback/EXTERNAL
|
||||
// posture judgment is expressed by a rule, not here.
|
||||
data.Coverage.WorkingS2S = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deriveIssues(data *XMPPData, wantC2S, _ bool) []Issue {
|
||||
var issues []Issue
|
||||
|
||||
// 1. No SRV published.
|
||||
if data.SRV.FallbackProbed {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoSRV,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No XMPP SRV records found for " + data.Domain + ".",
|
||||
Fix: "Publish _xmpp-client._tcp." + data.Domain + " and _xmpp-server._tcp." + data.Domain + " SRV records.",
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Legacy _jabber.
|
||||
if len(data.SRV.Jabber) > 0 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeLegacyJabber,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Obsolete _jabber._tcp SRV record still published.",
|
||||
Fix: "Remove _jabber._tcp records; _xmpp-client._tcp supersedes them.",
|
||||
})
|
||||
}
|
||||
|
||||
// 3. SRV lookup errors (real DNS failures, not NXDOMAIN).
|
||||
for prefix, msg := range data.SRV.Errors {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSRVServfail,
|
||||
Severity: SeverityWarn,
|
||||
Message: "DNS lookup failed for " + prefix + data.Domain + ": " + msg,
|
||||
Fix: "Check the authoritative DNS servers for this domain.",
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Endpoint-level issues.
|
||||
allDown := true
|
||||
sawSCRAM := map[XMPPMode]bool{}
|
||||
sawSCRAMPlus := map[XMPPMode]bool{}
|
||||
sawPlainOnly := map[XMPPMode]bool{}
|
||||
sawAnyWorking := map[XMPPMode]bool{}
|
||||
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected && ep.STARTTLSUpgraded {
|
||||
allDown = false
|
||||
sawAnyWorking[ep.Mode] = true
|
||||
}
|
||||
if ep.TCPConnected && ep.StreamOpened && !ep.DirectTLS {
|
||||
if !ep.STARTTLSOffered {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSMissing,
|
||||
Severity: SeverityCrit,
|
||||
Message: "STARTTLS not advertised on " + ep.Address + " (" + ep.SRVPrefix + ").",
|
||||
Fix: "Enable STARTTLS in the XMPP server configuration and require it for all connections.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
} else if !ep.STARTTLSRequired {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSNotRequired,
|
||||
Severity: SeverityWarn,
|
||||
Message: "STARTTLS offered but not <required/> on " + ep.Address + ".",
|
||||
Fix: "Set the server to require TLS (e.g. `c2s_require_encryption = true` in Prosody, `starttls_required` in ejabberd).",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
if ep.TCPConnected && !ep.STARTTLSUpgraded && ep.STARTTLSOffered && ep.Error != "" {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeStartTLSFailed,
|
||||
Severity: SeverityCrit,
|
||||
Message: "STARTTLS handshake failed on " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Run the TLS checker on this port for cert and cipher details.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
if !ep.TCPConnected && ep.Error != "" {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeTCPUnreachable,
|
||||
Severity: SeverityWarn,
|
||||
Message: "Cannot reach " + ep.Address + ": " + ep.Error + ".",
|
||||
Fix: "Verify firewall rules and that the XMPP server is listening on this address.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
// SASL posture (c2s only).
|
||||
if ep.Mode == ModeClient && ep.STARTTLSUpgraded && len(ep.SASLMechanisms) > 0 {
|
||||
hasSCRAM := false
|
||||
hasSCRAMPlus := false
|
||||
hasPlain := false
|
||||
nonPlain := false
|
||||
for _, m := range ep.SASLMechanisms {
|
||||
u := strings.ToUpper(m)
|
||||
if strings.HasPrefix(u, "SCRAM-") {
|
||||
hasSCRAM = true
|
||||
if strings.HasSuffix(u, "-PLUS") {
|
||||
hasSCRAMPlus = true
|
||||
}
|
||||
}
|
||||
if u == "PLAIN" {
|
||||
hasPlain = true
|
||||
} else {
|
||||
nonPlain = true
|
||||
}
|
||||
}
|
||||
if hasSCRAM {
|
||||
sawSCRAM[ep.Mode] = true
|
||||
}
|
||||
if hasSCRAMPlus {
|
||||
sawSCRAMPlus[ep.Mode] = true
|
||||
}
|
||||
if hasPlain && !nonPlain {
|
||||
sawPlainOnly[ep.Mode] = true
|
||||
}
|
||||
}
|
||||
// S2S auth posture, only meaningful if we actually parsed the
|
||||
// post-TLS features. Many public servers don't respond fully to
|
||||
// anonymous s2s probes; in that case we emit a probe_incomplete
|
||||
// info instead of falsely asserting "no auth".
|
||||
if ep.Mode == ModeServer && ep.STARTTLSUpgraded {
|
||||
if !ep.FeaturesRead {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeS2SProbeIncomplete,
|
||||
Severity: SeverityInfo,
|
||||
Message: "Could not read post-TLS stream features on " + ep.Address + "; server may require an authenticated origin for s2s.",
|
||||
Fix: "This is often benign for well-run public servers. Try from a real federating host if in doubt.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
} else if !ep.DialbackOffered && !ep.SASLExternal {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeS2SNoAuth,
|
||||
Severity: SeverityCrit,
|
||||
Message: "No dialback or SASL EXTERNAL advertised on " + ep.Address + " after TLS; federation will fail.",
|
||||
Fix: "Enable server-to-server dialback, or provision a cert usable for SASL EXTERNAL.",
|
||||
Endpoint: ep.Address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.Endpoints) > 0 && allDown {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeAllEndpointsDown,
|
||||
Severity: SeverityCrit,
|
||||
Message: "None of the XMPP endpoints could complete STARTTLS.",
|
||||
Fix: "Verify the server is running and reachable on the published SRV ports.",
|
||||
})
|
||||
}
|
||||
|
||||
if wantC2S && sawAnyWorking[ModeClient] {
|
||||
if !sawSCRAM[ModeClient] {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLNoSCRAM,
|
||||
Severity: SeverityWarn,
|
||||
Message: "No SCRAM-SHA-* SASL mechanism offered on c2s.",
|
||||
Fix: "Enable SCRAM-SHA-256 (and SCRAM-SHA-1 for compatibility).",
|
||||
})
|
||||
}
|
||||
if !sawSCRAMPlus[ModeClient] {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLNoSCRAMPlus,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No SCRAM-SHA-*-PLUS offered (channel binding).",
|
||||
Fix: "Enable SCRAM-SHA-256-PLUS to protect against TLS MITM.",
|
||||
})
|
||||
}
|
||||
if sawPlainOnly[ModeClient] {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeSASLPlainOnly,
|
||||
Severity: SeverityCrit,
|
||||
Message: "Only SASL PLAIN is offered on c2s.",
|
||||
Fix: "Enable SCRAM-SHA-256 so credentials are not sent as a password-equivalent hash.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// IPv6 coverage.
|
||||
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoIPv6,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No IPv6 endpoint reachable.",
|
||||
Fix: "Publish AAAA records for the SRV targets.",
|
||||
})
|
||||
}
|
||||
|
||||
// XEP-0368 direct TLS coverage.
|
||||
if wantC2S && sawAnyWorking[ModeClient] && len(data.SRV.ClientSecure) == 0 {
|
||||
issues = append(issues, Issue{
|
||||
Code: CodeNoDirectTLS,
|
||||
Severity: SeverityInfo,
|
||||
Message: "No XEP-0368 direct-TLS SRV record (_xmpps-client._tcp) published.",
|
||||
Fix: "Publish _xmpps-client._tcp SRV records pointing at port 5223 to allow TLS from the first byte.",
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue