Harden contract validation, STARTTLS edge cases, and rule output
This commit is contained in:
parent
a925e4f162
commit
fa212f0fae
9 changed files with 104 additions and 39 deletions
|
|
@ -68,7 +68,7 @@ func FetchChain(ctx context.Context, host string, port uint16, starttls string,
|
|||
|
||||
tlsConn := tls.Client(conn, &tls.Config{
|
||||
ServerName: host,
|
||||
InsecureSkipVerify: true,
|
||||
InsecureSkipVerify: true, // #nosec G402 -- intentional: caller receives the chain even when PKIX rejects it
|
||||
})
|
||||
if err := tlsConn.HandshakeContext(dialCtx); err != nil {
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration)
|
|||
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
|
||||
cfg := &tls.Config{
|
||||
ServerName: sni,
|
||||
InsecureSkipVerify: true,
|
||||
InsecureSkipVerify: true, // #nosec G402 -- intentional: chain verified separately in probe()
|
||||
}
|
||||
|
||||
if ep.STARTTLS == "" {
|
||||
|
|
@ -198,7 +198,7 @@ func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, e
|
|||
}
|
||||
|
||||
var (
|
||||
errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||
errStartTLSNotOffered = errors.New("starttls not advertised by server")
|
||||
errUnsupportedStartTLSProto = errors.New("unsupported starttls protocol")
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
|
@ -81,25 +83,38 @@ func (r *cipherSuiteRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
|
|||
return []sdk.CheckState{emptyCaseState("tls.cipher_suite.no_endpoints")}
|
||||
}
|
||||
|
||||
var out []sdk.CheckState
|
||||
// Collapse per-endpoint cipher suites into a single info state. One
|
||||
// row per endpoint drowns out actionable rules in the UI on domains
|
||||
// with many endpoints; an aggregated list is enough for visibility.
|
||||
suites := map[string]int{}
|
||||
endpoints := map[string][]string{}
|
||||
for _, ref := range sortedRefs(data) {
|
||||
p := data.Probes[ref]
|
||||
if p.CipherSuite == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "tls.cipher_suite.negotiated",
|
||||
Subject: subjectOf(p),
|
||||
Message: fmt.Sprintf("Cipher suite %s negotiated.", p.CipherSuite),
|
||||
Meta: metaOf(p),
|
||||
})
|
||||
suites[p.CipherSuite]++
|
||||
endpoints[p.CipherSuite] = append(endpoints[p.CipherSuite], p.Endpoint)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
if len(suites) == 0 {
|
||||
return []sdk.CheckState{unknownState(
|
||||
"tls.cipher_suite.skipped",
|
||||
"No endpoint completed a TLS handshake.",
|
||||
)}
|
||||
}
|
||||
return out
|
||||
names := make([]string, 0, len(suites))
|
||||
for s := range suites {
|
||||
names = append(names, s)
|
||||
}
|
||||
sort.Strings(names)
|
||||
parts := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", n, suites[n]))
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "tls.cipher_suite.negotiated",
|
||||
Message: "Negotiated cipher suites: " + strings.Join(parts, ", "),
|
||||
Meta: map[string]any{"suites": endpoints},
|
||||
}}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ func starttlsIMAP(conn net.Conn, sni string) error {
|
|||
supportsSTARTTLS = true
|
||||
}
|
||||
if strings.HasPrefix(line, "A001 ") {
|
||||
rest := strings.TrimSpace(line[len("A001 "):])
|
||||
if !strings.HasPrefix(strings.ToUpper(rest), "OK") {
|
||||
return fmt.Errorf("CAPABILITY rejected by server: %s", rest)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// EHLOHostname is the hostname sent in the SMTP EHLO command during STARTTLS
|
||||
// negotiation. Override it at startup (e.g. via -ldflags or programmatically)
|
||||
// to match the identity of the host running the checker.
|
||||
var EHLOHostname = "checker.localhost"
|
||||
|
||||
func init() {
|
||||
registerStartTLS("smtp", starttlsSMTP)
|
||||
registerStartTLS("submission", starttlsSMTP)
|
||||
|
|
@ -20,7 +25,7 @@ func starttlsSMTP(conn net.Conn, sni string) error {
|
|||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
|
||||
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil {
|
||||
if _, err := fmt.Fprintf(rw, "EHLO %s\r\n", EHLOHostname); err != nil {
|
||||
return fmt.Errorf("write ehlo: %w", err)
|
||||
}
|
||||
if err := rw.Flush(); err != nil {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ func starttlsXMPP(conn net.Conn, sni, ns string) error {
|
|||
dec := xml.NewDecoder(conn)
|
||||
|
||||
// Read the inbound <stream:stream> opening and its <stream:features>.
|
||||
// A peer that opens with <stream:error/> (or anything other than features)
|
||||
// is not going to advertise STARTTLS: surface that immediately rather
|
||||
// than spinning on tokens until the deadline fires.
|
||||
hasStartTLS := false
|
||||
outer:
|
||||
for {
|
||||
|
|
@ -38,29 +41,38 @@ outer:
|
|||
if err != nil {
|
||||
return fmt.Errorf("read stream features: %w", err)
|
||||
}
|
||||
if se, ok := tok.(xml.StartElement); ok {
|
||||
if se.Name.Local == "features" {
|
||||
// Scan features children.
|
||||
for {
|
||||
t2, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read features body: %w", err)
|
||||
se, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch se.Name.Local {
|
||||
case "stream":
|
||||
// Outer <stream:stream> opening. Continue reading children.
|
||||
continue
|
||||
case "features":
|
||||
for {
|
||||
t2, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read features body: %w", err)
|
||||
}
|
||||
switch ee := t2.(type) {
|
||||
case xml.StartElement:
|
||||
if ee.Name.Local == "starttls" {
|
||||
hasStartTLS = true
|
||||
}
|
||||
switch ee := t2.(type) {
|
||||
case xml.StartElement:
|
||||
if ee.Name.Local == "starttls" {
|
||||
hasStartTLS = true
|
||||
}
|
||||
if err := dec.Skip(); err != nil {
|
||||
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if ee.Name.Local == "features" {
|
||||
break outer
|
||||
}
|
||||
if err := dec.Skip(); err != nil {
|
||||
return fmt.Errorf("skip feature %q: %w", ee.Name.Local, err)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if ee.Name.Local == "features" {
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
case "error":
|
||||
return fmt.Errorf("server returned <stream:error/> before features")
|
||||
default:
|
||||
return fmt.Errorf("%w: unexpected element %q before features", errStartTLSNotOffered, se.Name.Local)
|
||||
}
|
||||
}
|
||||
if !hasStartTLS {
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ type TLSProbe struct {
|
|||
// no certificate.
|
||||
NoPeerCert bool `json:"no_peer_cert,omitempty"`
|
||||
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
HostnameMatch *bool `json:"hostname_match,omitempty"`
|
||||
ChainValid *bool `json:"chain_valid,omitempty"`
|
||||
ChainVerifyErr string `json:"chain_verify_err,omitempty"`
|
||||
NotAfter time.Time `json:"not_after,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
|
||||
// matching the CCADB CAA Identifiers CSV "Subject" column when the AKI
|
||||
// lookup misses.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
|
@ -58,10 +59,27 @@ type TLSEndpoint struct {
|
|||
RequireSTARTTLS bool `json:"require,omitempty"`
|
||||
}
|
||||
|
||||
// Validate rejects endpoints that cannot be probed: empty Host or zero Port.
|
||||
// STARTTLS dialect is intentionally not checked here (the checker surfaces
|
||||
// unsupported dialects at runtime via the tls.starttls_dialect_supported
|
||||
// rule), and SNI defaults to Host downstream.
|
||||
func (ep TLSEndpoint) Validate() error {
|
||||
if strings.TrimSpace(strings.TrimSuffix(ep.Host, ".")) == "" {
|
||||
return fmt.Errorf("contract: TLSEndpoint.Host is required")
|
||||
}
|
||||
if ep.Port == 0 {
|
||||
return fmt.Errorf("contract: TLSEndpoint.Port must be 1-65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref
|
||||
// derived from ep, and a marshaled Payload. The returned entry can be
|
||||
// returned as-is from a DiscoveryPublisher implementation.
|
||||
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
|
||||
if err := ep.Validate(); err != nil {
|
||||
return sdk.DiscoveryEntry{}, err
|
||||
}
|
||||
payload, err := json.Marshal(ep)
|
||||
if err != nil {
|
||||
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
|
||||
|
|
@ -95,7 +113,7 @@ func Ref(ep TLSEndpoint) string {
|
|||
req = "1"
|
||||
}
|
||||
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
|
||||
sum := sha1.Sum([]byte(canonical))
|
||||
sum := sha1.Sum([]byte(canonical)) // #nosec G401 G505 -- non-cryptographic stable key; see doc comment above
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +127,9 @@ func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) {
|
|||
if err := json.Unmarshal(e.Payload, &ep); err != nil {
|
||||
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
|
||||
}
|
||||
if err := ep.Validate(); err != nil {
|
||||
return TLSEndpoint{}, err
|
||||
}
|
||||
return ep, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -10,11 +10,19 @@ import (
|
|||
|
||||
var Version = "custom-build"
|
||||
|
||||
// EHLOHostname is set via -ldflags to identify this checker instance in SMTP
|
||||
// EHLO greetings. Falls back to the package default ("checker.localhost") when
|
||||
// left empty.
|
||||
var EHLOHostname = ""
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
tls.Version = Version
|
||||
if EHLOHostname != "" {
|
||||
tls.EHLOHostname = EHLOHostname
|
||||
}
|
||||
|
||||
srv := server.New(tls.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue