From 4398eeb9bca252307011a67037db9ea50d56c0ee Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 1 Mar 2026 17:53:07 -0500 Subject: [PATCH 1/5] fix(network): harden revocation fetch SSRF checks and connect timeout defaults --- CHANGELOG.md | 4 + README.md | 68 +++++++++-------- bundle.go | 114 +++++++++++++++++++++------- bundle_test.go | 11 +-- certkit_test.go | 103 +++++++++++++++++++++++-- cmd/certkit/connect.go | 26 ++++--- cmd/certkit/ocsp.go | 19 +++-- cmd/certkit/scan.go | 31 ++++---- cmd/certkit/verify.go | 41 +++++----- connect.go | 52 +++++++++---- connect_test.go | 135 +++++++++++++++++++++++---------- crl.go | 10 +-- crl_test.go | 41 +++++----- internal/certstore/aia.go | 13 ++-- internal/certstore/aia_test.go | 10 ++- internal/verify.go | 31 ++++---- internal/verify_test.go | 96 ++++++++++++----------- ocsp.go | 6 +- ocsp_test.go | 27 ++++++- 19 files changed, 565 insertions(+), 273 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661165e5..1c9f6d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Block DNS-rebind SSRF in AIA/OCSP/CRL fetches by validating DNS-resolved hostnames against private/internal address ranges by default, and add explicit `--allow-private-network` opt-in flags for internal PKI endpoints in `connect`, `verify`, `ocsp`, and `scan` ([#98]) - Prevent bundle export path traversal by sanitizing bundle folder names and enforcing safe output paths ([#87]) - Enforce size limits on input reads to avoid unbounded memory usage ([#87]) - Add SSRF validation (`ValidateAIAURL`) to OCSP responder URLs and CRL distribution point URLs — previously only AIA certificate URLs were validated ([#78]) @@ -95,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Apply a default 10-second timeout in `ConnectTLS` when callers provide a context without a deadline, preventing indefinite hangs during TCP/TLS connect and handshake operations ([#99]) - Fix verify JSON chain output to use `not_after` for consistency with other commands ([#87]) - Fix Certificate Transparency availability handling to preserve parsed SCT candidates when the log list cannot be loaded and mark them as unavailable instead of dropping them ([#86]) - Fix chain conversion failures in Certificate Transparency checks to report SCTs as `unavailable` instead of `invalid` and keep diagnostics as warnings ([#86]) @@ -937,6 +939,8 @@ Initial release. [#85]: https://github.com/sensiblebit/certkit/pull/85 [#86]: https://github.com/sensiblebit/certkit/pull/86 [#87]: https://github.com/sensiblebit/certkit/pull/87 +[#98]: https://github.com/sensiblebit/certkit/pull/98 +[#99]: https://github.com/sensiblebit/certkit/pull/99 [#73]: https://github.com/sensiblebit/certkit/pull/73 [#64]: https://github.com/sensiblebit/certkit/pull/64 [#63]: https://github.com/sensiblebit/certkit/pull/63 diff --git a/README.md b/README.md index 506380a1..35c500e2 100644 --- a/README.md +++ b/README.md @@ -139,15 +139,16 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always ### Verify Flags -| Flag | Default | Description | -| ---------------- | --------- | ------------------------------------------------------- | -| `--crl` | `false` | Check CRL distribution points for revocation | -| `--diagnose` | `false` | Show diagnostics when chain verification fails | -| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) | -| `--format` | `text` | Output format: text, json | -| `--key` | | Private key file to check against the certificate | -| `--ocsp` | `false` | Check OCSP revocation status | -| `--trust-store` | `mozilla` | Trust store: system, mozilla | +| Flag | Default | Description | +| ------------------------- | --------- | -------------------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | +| `--crl` | `false` | Check CRL distribution points for revocation | +| `--diagnose` | `false` | Show diagnostics when chain verification fails | +| `--expiry`, `-e` | | Check if cert expires within duration (e.g., 30d, 720h) | +| `--format` | `text` | Output format: text, json | +| `--key` | | Private key file to check against the certificate | +| `--ocsp` | `false` | Check OCSP revocation status | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | Chain verification is always performed. When the input contains an embedded private key (PKCS#12, JKS), key match is checked automatically. Use `--ocsp` and/or `--crl` to check revocation status (requires network access and a valid chain). @@ -155,13 +156,14 @@ Chain verification is always performed. When the input contains an embedded priv ### Connect Flags -| Flag | Default | Description | -| -------------- | ------- | ----------------------------------------------------------- | -| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings | -| `--crl` | `false` | Check CRL distribution points for revocation | -| `--format` | `text` | Output format: text, json | -| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | -| `--servername` | | Override SNI hostname (defaults to host) | +| Flag | Default | Description | +| ------------------------- | ------- | ----------------------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA/OCSP/CRL fetches to private/internal endpoints | +| `--ciphers` | `false` | Enumerate all supported cipher suites with security ratings | +| `--crl` | `false` | Check CRL distribution points for revocation | +| `--format` | `text` | Output format: text, json | +| `--no-ocsp` | `false` | Disable automatic OCSP revocation check | +| `--servername` | | Override SNI hostname (defaults to host) | Port defaults to 443 if not specified. OCSP revocation status is checked automatically (best-effort); use `--no-ocsp` to disable. Use `--verbose` for extended details (serial, key info, signature algorithm, key usage, EKU). @@ -217,18 +219,19 @@ Input format is auto-detected. ### Scan Flags -| Flag | Default | Description | -| ----------------- | ---------------- | -------------------------------------------------------- | -| `--bundle-path` | | Export bundles to this directory | -| `--config`, `-c` | `./bundles.yaml` | Path to bundle config YAML | -| `--dump-certs` | | Dump all discovered certificates to a single PEM file | -| `--dump-keys` | | Dump all discovered keys to a single PEM file | -| `--duplicates` | `false` | Export all certificates per bundle, not just the newest | -| `--force`, `-f` | `false` | Allow export of untrusted certificate bundles | -| `--format` | `text` | Output format: text, json | -| `--load-db` | | Load an existing database into memory before scanning | -| `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) | -| `--save-db` | | Save the in-memory database to disk after scanning | +| Flag | Default | Description | +| ------------------------- | ---------------- | -------------------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | +| `--bundle-path` | | Export bundles to this directory | +| `--config`, `-c` | `./bundles.yaml` | Path to bundle config YAML | +| `--dump-certs` | | Dump all discovered certificates to a single PEM file | +| `--dump-keys` | | Dump all discovered keys to a single PEM file | +| `--duplicates` | `false` | Export all certificates per bundle, not just the newest | +| `--force`, `-f` | `false` | Allow export of untrusted certificate bundles | +| `--format` | `text` | Output format: text, json | +| `--load-db` | | Load an existing database into memory before scanning | +| `--max-file-size` | `10485760` | Skip files larger than this size in bytes (0 to disable) | +| `--save-db` | | Save the in-memory database to disk after scanning | ### Keygen Flags @@ -264,10 +267,11 @@ Exactly one of `--template`, `--from-cert`, or `--from-csr` is required. ### OCSP Flags -| Flag | Default | Description | -| ---------- | ------- | ------------------------------------------------------------------ | -| `--format` | `text` | Output format: text, json | -| `--issuer` | | Issuer certificate file (PEM); auto-resolved from input if omitted | +| Flag | Default | Description | +| ------------------------- | ------- | ------------------------------------------------------------------ | +| `--allow-private-network` | `false` | Allow OCSP fetches to private/internal endpoints | +| `--format` | `text` | Output format: text, json | +| `--issuer` | | Issuer certificate file (PEM); auto-resolved from input if omitted | The OCSP responder URL is read from the certificate's AIA extension. diff --git a/bundle.go b/bundle.go index 5bfc8bcb..e7893fd5 100644 --- a/bundle.go +++ b/bundle.go @@ -34,6 +34,8 @@ var ( // address space. Parsed once at init to avoid repeated net.ParseCIDR calls. var privateNetworks []*net.IPNet +const aiaURLResolveTimeout = 2 * time.Second + func init() { for _, cidr := range []string{ "10.0.0.0/8", // RFC 1918 @@ -166,23 +168,42 @@ func IsIssuedByMozillaRoot(cert *x509.Certificate) bool { return MozillaRootSubjects()[string(cert.RawIssuer)] } -// ValidateAIAURL checks whether a URL is safe to fetch for AIA certificate -// resolution. It rejects non-HTTP(S) schemes and literal private/loopback/ -// link-local IP addresses to prevent SSRF. -// -// Known limitation: hostnames that resolve to private IPs are intentionally -// allowed. This means DNS rebinding (a hostname resolving to a public IP at -// validation time, then to a private IP at connection time) is theoretically -// possible. We accept this because: +// ValidateAIAURLInput holds parameters for ValidateAIAURLWithOptions. +type ValidateAIAURLInput struct { + // URL is the candidate URL to validate. + URL string + // AllowPrivateNetworks bypasses private/internal IP checks. + AllowPrivateNetworks bool + + lookupIPAddresses lookupIPAddressesFunc +} + +type lookupIPAddressesFunc func(ctx context.Context, host string) ([]net.IP, error) + +func defaultLookupIPAddresses(ctx context.Context, host string) ([]net.IP, error) { + return net.DefaultResolver.LookupIP(ctx, "ip", host) +} + +func ipBlockedForAIA(ip net.IP) error { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return fmt.Errorf("blocked address %s (loopback, link-local, or unspecified)", ip.String()) + } + for _, network := range privateNetworks { + if network.Contains(ip) { + return fmt.Errorf("blocked private address %s", ip.String()) + } + } + return nil +} + +// ValidateAIAURLWithOptions checks whether a URL is safe to fetch for AIA, +// OCSP, and CRL HTTP requests. // -// 1. certkit is a short-lived CLI process — the window between ValidateAIAURL -// and the HTTP request is ~2ms, making rebinding impractical to exploit. -// 2. Blocking hostnames that resolve to private IPs would break legitimate -// internal CAs whose AIA endpoints are on private networks. -// 3. Adding net.Dialer.Control to check resolved IPs doesn't help: if we -// allow private IPs for internal CAs, the check is the same TOCTOU race. -func ValidateAIAURL(rawURL string) error { - parsed, err := url.Parse(rawURL) +// By default, it rejects non-HTTP(S) schemes plus literal and DNS-resolved +// private/loopback/link-local/unspecified addresses to prevent SSRF and +// DNS-rebind attacks. Set AllowPrivateNetworks to bypass IP restrictions. +func ValidateAIAURLWithOptions(input ValidateAIAURLInput) error { + parsed, err := url.Parse(input.URL) if err != nil { return fmt.Errorf("parsing URL: %w", err) } @@ -193,21 +214,53 @@ func ValidateAIAURL(rawURL string) error { return fmt.Errorf("unsupported scheme %q (only http and https are allowed)", parsed.Scheme) } host := parsed.Hostname() + if host == "" { + return fmt.Errorf("missing hostname in URL") + } + + if input.AllowPrivateNetworks { + return nil + } + ip := net.ParseIP(host) - if ip == nil { - return nil // hostname, not a literal IP — allow (see doc comment) + if ip != nil { + if blockedErr := ipBlockedForAIA(ip); blockedErr != nil { + return blockedErr + } + return nil } - if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() { - return fmt.Errorf("blocked address %s (loopback, link-local, or unspecified)", host) + + lookup := input.lookupIPAddresses + if lookup == nil { + lookup = defaultLookupIPAddresses } - for _, network := range privateNetworks { - if network.Contains(ip) { - return fmt.Errorf("blocked private address %s", host) + + resolveCtx, cancel := context.WithTimeout(context.Background(), aiaURLResolveTimeout) + defer cancel() + + ips, err := lookup(resolveCtx, host) + if err != nil { + return fmt.Errorf("resolving host %q: %w", host, err) + } + if len(ips) == 0 { + return fmt.Errorf("resolving host %q: no IP addresses returned", host) + } + for _, resolvedIP := range ips { + if blockedErr := ipBlockedForAIA(resolvedIP); blockedErr != nil { + return fmt.Errorf("host %q resolved to %s: %w", host, resolvedIP.String(), blockedErr) } } + return nil } +// ValidateAIAURL checks whether a URL is safe to fetch for AIA, OCSP, and CRL +// requests. It rejects non-HTTP(S) schemes plus literal and DNS-resolved +// private/loopback/link-local/unspecified addresses. +func ValidateAIAURL(rawURL string) error { + return ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: rawURL}) +} + // VerifyChainTrustInput holds parameters for VerifyChainTrust. type VerifyChainTrustInput struct { Cert *x509.Certificate @@ -277,6 +330,8 @@ type BundleOptions struct { Verify bool // ExcludeRoot omits the root certificate from the result. ExcludeRoot bool + // AllowPrivateNetworks allows AIA fetches to private/internal endpoints. + AllowPrivateNetworks bool } // DefaultOptions returns sensible defaults. @@ -352,6 +407,8 @@ type FetchAIACertificatesInput struct { Timeout time.Duration // MaxDepth is the maximum number of AIA hops to follow. MaxDepth int + // AllowPrivateNetworks allows AIA fetches to private/internal endpoints. + AllowPrivateNetworks bool } // FetchAIACertificates follows AIA CA Issuers URLs to fetch intermediate certificates. @@ -369,7 +426,7 @@ func FetchAIACertificates(ctx context.Context, input FetchAIACertificatesInput) if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - if err := ValidateAIAURL(req.URL.String()); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } return nil @@ -388,7 +445,7 @@ func FetchAIACertificates(ctx context.Context, input FetchAIACertificatesInput) } seen[aiaURL] = true - if err := ValidateAIAURL(aiaURL); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { warnings = append(warnings, fmt.Sprintf("AIA URL rejected for %s: %v", aiaURL, err)) continue } @@ -532,9 +589,10 @@ func Bundle(ctx context.Context, input BundleInput) (*BundleResult, error) { if opts.FetchAIA { aiaCerts, warnings := FetchAIACertificates(ctx, FetchAIACertificatesInput{ - Cert: leaf, - Timeout: opts.AIATimeout, - MaxDepth: opts.AIAMaxDepth, + Cert: leaf, + Timeout: opts.AIATimeout, + MaxDepth: opts.AIAMaxDepth, + AllowPrivateNetworks: opts.AllowPrivateNetworks, }) result.Warnings = append(result.Warnings, warnings...) for _, cert := range aiaCerts { diff --git a/bundle_test.go b/bundle_test.go index 7237f7c6..672d905c 100644 --- a/bundle_test.go +++ b/bundle_test.go @@ -598,8 +598,8 @@ func TestFetchAIACertificates_duplicateURLs(t *testing.T) { if err != nil { t.Fatal(err) } - // Replace 127.0.0.1 with localhost to avoid ValidateAIAURL SSRF blocking - // of literal loopback IPs. Hostname-based URLs pass SSRF validation. + // Replace 127.0.0.1 with localhost and opt in to private networks for this + // local integration test. srvURL := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1) leafTemplate := &x509.Certificate{ @@ -623,9 +623,10 @@ func TestFetchAIACertificates_duplicateURLs(t *testing.T) { } fetched, _ := FetchAIACertificates(context.Background(), FetchAIACertificatesInput{ - Cert: leafCert, - Timeout: 2 * time.Second, - MaxDepth: 5, + Cert: leafCert, + Timeout: 2 * time.Second, + MaxDepth: 5, + AllowPrivateNetworks: true, }) if len(fetched) != 1 { t.Errorf("expected 1 fetched cert (deduped), got %d", len(fetched)) diff --git a/certkit_test.go b/certkit_test.go index f405e0b6..a605966d 100644 --- a/certkit_test.go +++ b/certkit_test.go @@ -1,6 +1,7 @@ package certkit import ( + "context" "crypto" "crypto/ecdsa" "crypto/ed25519" @@ -15,6 +16,7 @@ import ( "encoding/hex" "encoding/pem" "fmt" + "net" "slices" "strings" "testing" @@ -1771,8 +1773,7 @@ func TestVerifyChainTrust(t *testing.T) { func TestValidateAIAURL(t *testing.T) { // WHY: ValidateAIAURL prevents SSRF by rejecting non-HTTP schemes and - // literal private/loopback/link-local IP addresses. Each case covers a - // distinct rejection rule or an allowed pattern. + // private/loopback/link-local/unspecified IPs. t.Parallel() tests := []struct { @@ -1781,13 +1782,15 @@ func TestValidateAIAURL(t *testing.T) { wantErr bool errSub string }{ - {"valid http", "http://ca.example.com/issuer.cer", false, ""}, - {"valid https", "https://ca.example.com/issuer.cer", false, ""}, + {"valid public IPv4 http", "http://8.8.8.8/issuer.cer", false, ""}, + {"valid public IPv4 https", "https://8.8.8.8/issuer.cer", false, ""}, {"ftp rejected", "ftp://ca.example.com/issuer.cer", true, "unsupported scheme"}, {"file rejected", "file:///etc/passwd", true, "unsupported scheme"}, {"empty scheme rejected", "://foo", true, "parsing URL"}, + {"missing hostname rejected", "https:///issuer.cer", true, "missing hostname"}, {"loopback IPv4", "http://127.0.0.1/ca.cer", true, "loopback"}, {"loopback IPv6", "http://[::1]/ca.cer", true, "loopback"}, + {"localhost hostname", "http://localhost/ca.cer", true, "resolved"}, {"link-local IPv4", "http://169.254.1.1/ca.cer", true, "loopback, link-local, or unspecified"}, {"unspecified IPv4", "http://0.0.0.0/ca.cer", true, "loopback, link-local, or unspecified"}, {"unspecified IPv6", "http://[::]/ca.cer", true, "loopback, link-local, or unspecified"}, @@ -1796,14 +1799,18 @@ func TestValidateAIAURL(t *testing.T) { {"private 172.16.x", "http://172.16.0.1/ca.cer", true, "blocked private"}, {"private 192.168.x", "http://192.168.1.1/ca.cer", true, "blocked private"}, {"CGN 100.64.x", "http://100.64.0.1/ca.cer", true, "blocked private"}, - {"public IP allowed", "http://8.8.8.8/ca.cer", false, ""}, - {"hostname allowed even if resolves to private", "http://internal.company.com/ca.cer", false, ""}, + {"allow private network option", "http://127.0.0.1/ca.cer", false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := ValidateAIAURL(tt.url) + var err error + if tt.name == "allow private network option" { + err = ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: tt.url, AllowPrivateNetworks: true}) + } else { + err = ValidateAIAURL(tt.url) + } if tt.wantErr { if err == nil { t.Fatalf("expected error for %q", tt.url) @@ -1820,6 +1827,88 @@ func TestValidateAIAURL(t *testing.T) { } } +func TestValidateAIAURLWithOptions_HostnameResolution(t *testing.T) { + t.Parallel() + + lookup := func(_ context.Context, host string) ([]net.IP, error) { + switch host { + case "public.example": + return []net.IP{net.ParseIP("93.184.216.34")}, nil + case "mixed.example": + return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("10.0.0.10")}, nil + case "empty.example": + return nil, nil + default: + return nil, fmt.Errorf("lookup failed") + } + } + + tests := []struct { + name string + input ValidateAIAURLInput + wantErr string + }{ + { + name: "public resolution allowed", + input: ValidateAIAURLInput{ + URL: "https://public.example/issuer.cer", + lookupIPAddresses: lookup, + }, + }, + { + name: "mixed public and private blocked", + input: ValidateAIAURLInput{ + URL: "https://mixed.example/issuer.cer", + lookupIPAddresses: lookup, + }, + wantErr: "blocked private address", + }, + { + name: "empty DNS answer blocked", + input: ValidateAIAURLInput{ + URL: "https://empty.example/issuer.cer", + lookupIPAddresses: lookup, + }, + wantErr: "no IP addresses returned", + }, + { + name: "resolver error blocked", + input: ValidateAIAURLInput{ + URL: "https://error.example/issuer.cer", + lookupIPAddresses: lookup, + }, + wantErr: "resolving host", + }, + { + name: "allow private bypasses DNS checks", + input: ValidateAIAURLInput{ + URL: "https://mixed.example/issuer.cer", + AllowPrivateNetworks: true, + lookupIPAddresses: lookup, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateAIAURLWithOptions(tt.input) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestAlgorithmName(t *testing.T) { // WHY: KeyAlgorithmName and PublicKeyAlgorithmName produce display strings // for CLI output and JSON; wrong names would confuse users and break JSON diff --git a/cmd/certkit/connect.go b/cmd/certkit/connect.go index 8ae6e092..12af2779 100644 --- a/cmd/certkit/connect.go +++ b/cmd/certkit/connect.go @@ -18,11 +18,12 @@ import ( ) var ( - connectServerName string - connectFormat string - connectCRL bool - connectNoOCSP bool - connectCiphers bool + connectServerName string + connectFormat string + connectCRL bool + connectNoOCSP bool + connectCiphers bool + connectAllowPrivateNetwork bool ) var connectCmd = &cobra.Command{ @@ -36,6 +37,9 @@ automatically (best-effort). Use --no-ocsp to disable. Use --crl to also check CRL distribution points. Use --ciphers to enumerate all cipher suites the server supports with security ratings. +Network fetches for AIA/OCSP/CRL block private/internal endpoints by default. +Use --allow-private-network to opt in for internal PKI environments. + Exits with code 2 if chain verification fails or the certificate is revoked.`, Example: ` certkit connect example.com certkit connect example.com:8443 @@ -53,6 +57,7 @@ func init() { connectCmd.Flags().BoolVar(&connectCRL, "crl", false, "Check CRL distribution points for revocation") connectCmd.Flags().BoolVar(&connectNoOCSP, "no-ocsp", false, "Disable automatic OCSP revocation check") connectCmd.Flags().BoolVar(&connectCiphers, "ciphers", false, "Enumerate all supported cipher suites with security ratings") + connectCmd.Flags().BoolVar(&connectAllowPrivateNetwork, "allow-private-network", false, "Allow AIA/OCSP/CRL fetches to private/internal endpoints") registerCompletion(connectCmd, completionInput{"format", fixedCompletion("text", "json")}) } @@ -114,11 +119,12 @@ func runConnect(cmd *cobra.Command, args []string) error { defer cancel() result, err := certkit.ConnectTLS(ctx, certkit.ConnectTLSInput{ - Host: host, - Port: port, - ServerName: connectServerName, - DisableOCSP: connectNoOCSP, - CheckCRL: connectCRL, + Host: host, + Port: port, + ServerName: connectServerName, + DisableOCSP: connectNoOCSP, + CheckCRL: connectCRL, + AllowPrivateNetworks: connectAllowPrivateNetwork, }) if err != nil { spin.Stop() diff --git a/cmd/certkit/ocsp.go b/cmd/certkit/ocsp.go index cd635723..25f7f935 100644 --- a/cmd/certkit/ocsp.go +++ b/cmd/certkit/ocsp.go @@ -11,8 +11,9 @@ import ( ) var ( - ocspIssuerPath string - ocspFormat string + ocspIssuerPath string + ocspFormat string + ocspAllowPrivateNetwork bool ) var ocspCmd = &cobra.Command{ @@ -22,7 +23,8 @@ var ocspCmd = &cobra.Command{ The OCSP responder URL is read from the certificate's AIA extension. Use --issuer to provide the issuer certificate if it is not embedded -in the input file. +in the input file. Private/internal OCSP endpoints are blocked by default; +use --allow-private-network to opt in. Exits with code 2 if the certificate is revoked.`, Example: ` certkit ocsp cert.pem --issuer issuer.pem @@ -35,6 +37,7 @@ Exits with code 2 if the certificate is revoked.`, func init() { ocspCmd.Flags().StringVar(&ocspIssuerPath, "issuer", "", "Issuer certificate file (PEM); auto-resolved from input if omitted") ocspCmd.Flags().StringVar(&ocspFormat, "format", "text", "Output format: text, json") + ocspCmd.Flags().BoolVar(&ocspAllowPrivateNetwork, "allow-private-network", false, "Allow OCSP fetches to private/internal endpoints") registerCompletion(ocspCmd, completionInput{"issuer", fileCompletion}) registerCompletion(ocspCmd, completionInput{"format", fixedCompletion("text", "json")}) @@ -74,14 +77,16 @@ func runOCSP(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing issuer certificate: %w", err) } ocspInput = &certkit.CheckOCSPInput{ - Cert: contents.Leaf, - Issuer: issuerCert, + Cert: contents.Leaf, + Issuer: issuerCert, + AllowPrivateNetworks: ocspAllowPrivateNetwork, } } else if len(contents.ExtraCerts) > 0 { // Use first extra cert as issuer (typically the immediate issuer) ocspInput = &certkit.CheckOCSPInput{ - Cert: contents.Leaf, - Issuer: contents.ExtraCerts[0], + Cert: contents.Leaf, + Issuer: contents.ExtraCerts[0], + AllowPrivateNetworks: ocspAllowPrivateNetwork, } } else { return fmt.Errorf("no issuer certificate found; use --issuer to provide one") diff --git a/cmd/certkit/scan.go b/cmd/certkit/scan.go index bd9058e7..1307bac6 100644 --- a/cmd/certkit/scan.go +++ b/cmd/certkit/scan.go @@ -22,16 +22,17 @@ import ( ) var ( - scanLoadDB string - scanSaveDB string - scanConfigPath string - scanBundlePath string - scanForceExport bool - scanDuplicates bool - scanDumpKeys string - scanDumpCerts string - scanMaxFileSize int64 - scanFormat string + scanLoadDB string + scanSaveDB string + scanConfigPath string + scanBundlePath string + scanForceExport bool + scanDuplicates bool + scanDumpKeys string + scanDumpCerts string + scanMaxFileSize int64 + scanFormat string + scanAllowPrivateNetwork bool ) var scanCmd = &cobra.Command{ @@ -55,6 +56,7 @@ func init() { scanCmd.Flags().StringVar(&scanDumpCerts, "dump-certs", "", "Dump all discovered certificates to a single PEM file") scanCmd.Flags().Int64Var(&scanMaxFileSize, "max-file-size", 10*1024*1024, "Skip files larger than this size in bytes (0 to disable)") scanCmd.Flags().StringVar(&scanFormat, "format", "text", "Output format: text, json") + scanCmd.Flags().BoolVar(&scanAllowPrivateNetwork, "allow-private-network", false, "Allow AIA fetches to private/internal endpoints") scanCmd.Flags().StringVar(&scanSaveDB, "save-db", "", "Save the in-memory database to disk after scanning") scanCmd.Flags().StringVar(&scanLoadDB, "load-db", "", "Load an existing database into memory before scanning") @@ -183,8 +185,9 @@ func runScan(cmd *cobra.Command, args []string) error { if certstore.HasUnresolvedIssuers(store) { slog.Info("resolving certificate chains") aiaWarnings := certstore.ResolveAIA(cmd.Context(), certstore.ResolveAIAInput{ - Store: store, - Fetch: httpAIAFetcher, + Store: store, + Fetch: httpAIAFetcher, + AllowPrivateNetworks: scanAllowPrivateNetwork, }) for _, w := range aiaWarnings { slog.Warn("AIA resolution", "warning", w) @@ -481,7 +484,7 @@ var aiaHTTPClient = &http.Client{ if len(via) >= 3 { return fmt.Errorf("stopped after 3 redirects") } - if err := certkit.ValidateAIAURL(req.URL.String()); err != nil { + if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: scanAllowPrivateNetwork}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } return nil @@ -490,7 +493,7 @@ var aiaHTTPClient = &http.Client{ // httpAIAFetcher fetches raw certificate bytes from a URL via HTTP. func httpAIAFetcher(ctx context.Context, rawURL string) ([]byte, error) { - if err := certkit.ValidateAIAURL(rawURL); err != nil { + if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: rawURL, AllowPrivateNetworks: scanAllowPrivateNetwork}); err != nil { return nil, fmt.Errorf("AIA URL rejected: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) diff --git a/cmd/certkit/verify.go b/cmd/certkit/verify.go index e3485380..da90584a 100644 --- a/cmd/certkit/verify.go +++ b/cmd/certkit/verify.go @@ -15,13 +15,14 @@ import ( ) var ( - verifyKeyPath string - verifyExpiry string - verifyTrustStore string - verifyFormat string - verifyDiagnose bool - verifyOCSP bool - verifyCRL bool + verifyKeyPath string + verifyExpiry string + verifyTrustStore string + verifyFormat string + verifyDiagnose bool + verifyOCSP bool + verifyCRL bool + verifyAllowPrivateNetwork bool ) var verifyCmd = &cobra.Command{ @@ -36,7 +37,9 @@ is checked automatically. Use --key to check against an external key file. Use --ocsp to check OCSP revocation status, and --crl to check CRL distribution points. Both require network access and a valid chain (the issuer certificate -is needed to verify the response). Exits with code 2 if verification finds any errors (including revocation).`, +is needed to verify the response). Network fetches for AIA/OCSP/CRL block +private/internal endpoints by default; use --allow-private-network to opt in. +Exits with code 2 if verification finds any errors (including revocation).`, Example: ` certkit verify cert.pem certkit verify cert.pem --key key.pem certkit verify cert.pem --expiry 30d @@ -57,6 +60,7 @@ func init() { verifyCmd.Flags().BoolVar(&verifyDiagnose, "diagnose", false, "Show diagnostics when chain verification fails") verifyCmd.Flags().BoolVar(&verifyOCSP, "ocsp", false, "Check OCSP revocation status") verifyCmd.Flags().BoolVar(&verifyCRL, "crl", false, "Check CRL distribution points for revocation") + verifyCmd.Flags().BoolVar(&verifyAllowPrivateNetwork, "allow-private-network", false, "Allow AIA/OCSP/CRL fetches to private/internal endpoints") registerCompletion(verifyCmd, completionInput{"format", fixedCompletion("text", "json")}) registerCompletion(verifyCmd, completionInput{"trust-store", fixedCompletion("system", "mozilla")}) @@ -128,16 +132,17 @@ func runVerify(cmd *cobra.Command, args []string) error { } input := &internal.VerifyInput{ - Cert: contents.Leaf, - Key: key, - ExtraCerts: contents.ExtraCerts, - CheckKeyMatch: key != nil, - CheckChain: true, // Always verify chain - ExpiryDuration: expiryDuration, - TrustStore: verifyTrustStore, - Verbose: verbose, - CheckOCSP: verifyOCSP, - CheckCRL: verifyCRL, + Cert: contents.Leaf, + Key: key, + ExtraCerts: contents.ExtraCerts, + CheckKeyMatch: key != nil, + CheckChain: true, // Always verify chain + ExpiryDuration: expiryDuration, + TrustStore: verifyTrustStore, + Verbose: verbose, + CheckOCSP: verifyOCSP, + CheckCRL: verifyCRL, + AllowPrivateNetworks: verifyAllowPrivateNetwork, } result, err := internal.VerifyCert(cmd.Context(), input) diff --git a/connect.go b/connect.go index 47999944..677b06d4 100644 --- a/connect.go +++ b/connect.go @@ -17,6 +17,8 @@ import ( "time" ) +const defaultConnectTimeout = 10 * time.Second + // ChainDiagnostic describes a single chain configuration issue found during connection probing. type ChainDiagnostic struct { // Check is the diagnostic identifier (e.g. "root-in-chain", "duplicate-cert", "missing-intermediate"). @@ -167,6 +169,8 @@ type ConnectTLSInput struct { Host string // Port is the TCP port (default: "443"). Port string + // ConnectTimeout is used when ctx has no deadline (default: 10s). + ConnectTimeout time.Duration // ServerName overrides the SNI hostname (defaults to Host). ServerName string // DisableAIA disables automatic AIA certificate fetching when chain verification fails. @@ -184,6 +188,8 @@ type ConnectTLSInput struct { // RootCAs overrides system roots for chain verification. When nil, // the system root pool is used. Useful for testing against private CAs. RootCAs *x509.CertPool + // AllowPrivateNetworks allows AIA/OCSP/CRL fetches to private/internal endpoints. + AllowPrivateNetworks bool } // ClientAuthInfo describes the server's client certificate request (mTLS). @@ -275,8 +281,19 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err addr := net.JoinHostPort(input.Host, port) + connectCtx := ctx + connectCancel := func() {} + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + connectTimeout := input.ConnectTimeout + if connectTimeout == 0 { + connectTimeout = defaultConnectTimeout + } + connectCtx, connectCancel = context.WithTimeout(ctx, connectTimeout) + } + defer connectCancel() + dialer := &net.Dialer{} - conn, err := dialer.DialContext(ctx, "tcp", addr) + conn, err := dialer.DialContext(connectCtx, "tcp", addr) if err != nil { return nil, fmt.Errorf("connecting to %s: %w", addr, err) } @@ -312,13 +329,13 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err tlsConn := tls.Client(conn, tlsConf) defer func() { _ = tlsConn.Close() }() - if deadline, ok := ctx.Deadline(); ok { + if deadline, ok := connectCtx.Deadline(); ok { if err := tlsConn.SetDeadline(deadline); err != nil { return nil, fmt.Errorf("setting deadline: %w", err) } } - handshakeErr := tlsConn.HandshakeContext(ctx) + handshakeErr := tlsConn.HandshakeContext(connectCtx) var tlsAlert tls.AlertError if handshakeErr != nil && clientAuth == nil && errors.As(handshakeErr, &tlsAlert) { // Close the failed TLS connection before opening a new one. @@ -330,7 +347,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err // negotiation failure), not for network errors or certificate errors. // Use a dedicated timeout so a stalling server can't hold the // fallback connection open indefinitely. - fallbackCtx, fallbackCancel := context.WithTimeout(ctx, 5*time.Second) + fallbackCtx, fallbackCancel := context.WithTimeout(connectCtx, 5*time.Second) defer fallbackCancel() legacyResult, legacyErr := legacyFallbackConnect(fallbackCtx, legacyFallbackInput{ addr: addr, @@ -348,7 +365,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err PeerChain: legacyResult.certificates, LegacyProbe: true, } - result.populate(ctx, input) + result.populate(connectCtx, input) result.Diagnostics = append(result.Diagnostics, ChainDiagnostic{ Check: "legacy-only", Status: "warn", @@ -380,7 +397,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err TLSSCTs: state.SignedCertificateTimestamps, } - result.populate(ctx, input) + result.populate(connectCtx, input) return result, nil } @@ -419,9 +436,10 @@ func (result *ConnectResult) populate(ctx context.Context, input ConnectTLSInput aiaTimeout = 5 * time.Second } aiaCerts, aiaWarnings := FetchAIACertificates(ctx, FetchAIACertificatesInput{ - Cert: leaf, - Timeout: aiaTimeout, - MaxDepth: 5, + Cert: leaf, + Timeout: aiaTimeout, + MaxDepth: 5, + AllowPrivateNetworks: input.AllowPrivateNetworks, }) for _, w := range aiaWarnings { slog.Debug("AIA fetch warning", "warning", w) @@ -508,8 +526,9 @@ func (result *ConnectResult) populate(ctx context.Context, input ConnectTLSInput } ocspCtx, ocspCancel := context.WithTimeout(ctx, ocspTimeout) ocspResult, ocspErr := CheckOCSP(ocspCtx, CheckOCSPInput{ - Cert: leaf, - Issuer: issuer, + Cert: leaf, + Issuer: issuer, + AllowPrivateNetworks: input.AllowPrivateNetworks, }) ocspCancel() if ocspErr != nil { @@ -527,9 +546,10 @@ func (result *ConnectResult) populate(ctx context.Context, input ConnectTLSInput // Opt-in CRL check on the leaf certificate. if input.CheckCRL && issuer != nil { result.CRL = CheckLeafCRL(ctx, CheckLeafCRLInput{ - Leaf: leaf, - Issuer: issuer, - Timeout: input.CRLTimeout, + Leaf: leaf, + Issuer: issuer, + Timeout: input.CRLTimeout, + AllowPrivateNetworks: input.AllowPrivateNetworks, }) } else if input.CheckCRL { result.CRL = &CRLCheckResult{ @@ -547,6 +567,8 @@ type CheckLeafCRLInput struct { Issuer *x509.Certificate // Timeout is the timeout for fetching the CRL (default: 5s). Timeout time.Duration + // AllowPrivateNetworks allows CRL fetches to private/internal endpoints. + AllowPrivateNetworks bool } // CheckLeafCRL fetches the first HTTP CRL distribution point and checks whether @@ -589,7 +611,7 @@ func CheckLeafCRL(ctx context.Context, input CheckLeafCRLInput) *CRLCheckResult crlCtx, crlCancel := context.WithTimeout(ctx, timeout) defer crlCancel() - data, err := FetchCRL(crlCtx, FetchCRLInput{URL: cdpURL}) + data, err := FetchCRL(crlCtx, FetchCRLInput{URL: cdpURL, AllowPrivateNetworks: input.AllowPrivateNetworks}) if err != nil { slog.Debug("CRL fetch failed", "url", cdpURL, "error", err) return &CRLCheckResult{ diff --git a/connect_test.go b/connect_test.go index 4f533cf8..e34c29ef 100644 --- a/connect_test.go +++ b/connect_test.go @@ -592,6 +592,48 @@ func TestConnectTLS_CancelledContext(t *testing.T) { } } +func TestConnectTLS_DefaultTimeoutWhenContextHasNoDeadline(t *testing.T) { + // WHY: ConnectTLS must apply a safe timeout when callers pass context.Background() + // so stalled handshakes do not block indefinitely. + t.Parallel() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + // Hold the socket open without speaking TLS until the client times out. + time.Sleep(250 * time.Millisecond) + _ = conn.Close() + } + }() + + _, portStr, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + + start := time.Now() + _, err = ConnectTLS(context.Background(), ConnectTLSInput{ + Host: "127.0.0.1", + Port: portStr, + ConnectTimeout: 50 * time.Millisecond, + }) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("ConnectTLS took too long without context deadline: %s", elapsed) + } +} + func TestConnectTLS_IPv6Loopback(t *testing.T) { // WHY: ConnectTLS should accept IPv6 hosts (with ServerName override) when available. t.Parallel() @@ -1393,10 +1435,11 @@ func TestConnectTLS_AIAFetch(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: portStr, - AIATimeout: 5 * time.Second, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: portStr, + AIATimeout: 5 * time.Second, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -1496,9 +1539,10 @@ func TestConnectTLS_RootInChainDiagnostic(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -1553,9 +1597,10 @@ func TestConnectTLS_AIAFetch_FallbackURL(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -1728,10 +1773,11 @@ func TestConnectTLS_AIAFetch_Failure(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: portStr, - AIATimeout: tc.aiaTimeout, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: portStr, + AIATimeout: tc.aiaTimeout, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -1791,9 +1837,10 @@ func TestConnectTLS_AIAFetch_WrongIssuer(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -2054,9 +2101,10 @@ func TestConnectTLS_OCSP(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -2155,10 +2203,11 @@ func TestConnectTLS_OCSP_SkipAndFailure(t *testing.T) { rootPool.AddCert(ca.Cert) result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, - DisableOCSP: tc.disableOCSP, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + DisableOCSP: tc.disableOCSP, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -2256,9 +2305,10 @@ func TestConnectTLS_OCSP_InvalidResponses(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -2412,11 +2462,12 @@ func TestConnectTLS_CRL(t *testing.T) { rootPool.AddCert(ca.Cert) result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - RootCAs: rootPool, - CheckCRL: true, - DisableOCSP: true, + Host: "127.0.0.1", + Port: port, + RootCAs: rootPool, + CheckCRL: true, + DisableOCSP: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -2617,11 +2668,12 @@ func TestConnectTLS_CRL_AIAFetchedIssuer(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - CheckCRL: true, - DisableOCSP: true, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + CheckCRL: true, + DisableOCSP: true, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) @@ -3263,10 +3315,11 @@ func TestConnectTLS_CRL_DuplicateLeafInChain(t *testing.T) { defer cancel() result, err := ConnectTLS(ctx, ConnectTLSInput{ - Host: "127.0.0.1", - Port: port, - CheckCRL: true, - RootCAs: rootPool, + Host: "127.0.0.1", + Port: port, + CheckCRL: true, + RootCAs: rootPool, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("ConnectTLS failed: %v", err) diff --git a/crl.go b/crl.go index 25a4a81a..9b4be75c 100644 --- a/crl.go +++ b/crl.go @@ -40,13 +40,13 @@ type FetchCRLInput struct { } // FetchCRL downloads a CRL from an HTTP or HTTPS URL. -// By default, the URL is validated against SSRF (literal private/loopback IPs -// are blocked; hostnames are allowed). Set AllowPrivateNetworks to bypass this -// for user-provided URLs. +// By default, the URL is validated against SSRF (literal and DNS-resolved +// private/loopback/link-local/unspecified IPs are blocked). Set +// AllowPrivateNetworks to bypass this for user-provided URLs. // The response is limited to 10 MB. func FetchCRL(ctx context.Context, input FetchCRLInput) ([]byte, error) { if !input.AllowPrivateNetworks { - if err := ValidateAIAURL(input.URL); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: input.URL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return nil, fmt.Errorf("validating CRL URL: %w", err) } } @@ -59,7 +59,7 @@ func FetchCRL(ctx context.Context, input FetchCRLInput) ([]byte, error) { return fmt.Errorf("stopped after %d redirects", maxRedirects) } if !input.AllowPrivateNetworks { - if err := ValidateAIAURL(req.URL.String()); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } } diff --git a/crl_test.go b/crl_test.go index ad18e6d4..f52d3e22 100644 --- a/crl_test.go +++ b/crl_test.go @@ -195,32 +195,28 @@ func TestFetchCRL(t *testing.T) { } tests := []struct { - name string - handler http.HandlerFunc // nil = no server needed (use overrideURL) - overrideURL string // direct URL (bypasses test server) - wantErr string - wantLength int + name string + handler http.HandlerFunc // nil = no server needed (use overrideURL) + overrideURL string // direct URL (bypasses test server) + allowPrivate bool + wantErr string + wantLength int }{ { name: "success", handler: func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(crlDER) }, - wantLength: len(crlDER), + allowPrivate: true, + wantLength: len(crlDER), }, { name: "non-200 status", handler: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }, - wantErr: "HTTP 404", - }, - { - name: "redirect to private IP blocked", - handler: func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "http://127.0.0.1/crl", http.StatusFound) - }, - wantErr: "redirect blocked", + allowPrivate: true, + wantErr: "HTTP 404", }, { name: "too many redirects", @@ -228,13 +224,19 @@ func TestFetchCRL(t *testing.T) { // Redirect back to self — after 3 hops the client stops. http.Redirect(w, r, r.URL.String(), http.StatusFound) }, - wantErr: "stopped after 3 redirects", + allowPrivate: true, + wantErr: "stopped after 3 redirects", }, { name: "SSRF blocked loopback IP", overrideURL: "http://127.0.0.1/crl", wantErr: "validating CRL URL", }, + { + name: "SSRF blocked localhost hostname", + overrideURL: "http://localhost/crl", + wantErr: "validating CRL URL", + }, { name: "invalid scheme", overrideURL: "ftp://example.com/crl", @@ -250,11 +252,11 @@ func TestFetchCRL(t *testing.T) { if tc.handler != nil { srv := httptest.NewServer(tc.handler) t.Cleanup(srv.Close) - // Replace 127.0.0.1 with localhost to pass SSRF validation. + // Use localhost for deterministic loopback testing. fetchURL = strings.Replace(srv.URL, "127.0.0.1", "localhost", 1) } - data, err := FetchCRL(context.Background(), FetchCRLInput{URL: fetchURL}) + data, err := FetchCRL(context.Background(), FetchCRLInput{URL: fetchURL, AllowPrivateNetworks: tc.allowPrivate}) if tc.wantErr != "" { if err == nil { t.Fatalf("expected error containing %q, got nil", tc.wantErr) @@ -440,8 +442,9 @@ func TestCheckLeafCRL(t *testing.T) { } result := CheckLeafCRL(context.Background(), CheckLeafCRLInput{ - Leaf: leaf, - Issuer: ca.Cert, + Leaf: leaf, + Issuer: ca.Cert, + AllowPrivateNetworks: true, }) if result.Status != tc.wantStatus { t.Errorf("Status = %q, want %q", result.Status, tc.wantStatus) diff --git a/internal/certstore/aia.go b/internal/certstore/aia.go index cb76872e..b872dea5 100644 --- a/internal/certstore/aia.go +++ b/internal/certstore/aia.go @@ -16,11 +16,12 @@ type AIAFetcher func(ctx context.Context, url string) ([]byte, error) // ResolveAIAInput holds parameters for ResolveAIA. type ResolveAIAInput struct { - Store *MemStore - Fetch AIAFetcher - MaxDepth int // 0 defaults to 5 - Concurrency int // 0 defaults to 20; max parallel fetches per round - OnProgress func(completed, total int) // optional; called after each cert's AIA URLs are processed + Store *MemStore + Fetch AIAFetcher + MaxDepth int // 0 defaults to 5 + Concurrency int // 0 defaults to 20; max parallel fetches per round + OnProgress func(completed, total int) // optional; called after each cert's AIA URLs are processed + AllowPrivateNetworks bool } // HasUnresolvedIssuers reports whether any non-root certificate in the store @@ -134,7 +135,7 @@ func ResolveAIA(ctx context.Context, input ResolveAIAInput) []string { } seen[aiaURL] = true - if err := certkit.ValidateAIAURL(aiaURL); err != nil { + if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { warnings = append(warnings, fmt.Sprintf( "AIA URL rejected for %q: %v", rec.Cert.Subject.CommonName, err, diff --git a/internal/certstore/aia_test.go b/internal/certstore/aia_test.go index 06ddc467..21247336 100644 --- a/internal/certstore/aia_test.go +++ b/internal/certstore/aia_test.go @@ -221,8 +221,9 @@ func TestResolveAIA_FetchesMissingIssuer(t *testing.T) { } warnings := ResolveAIA(context.Background(), ResolveAIAInput{ - Store: store, - Fetch: fetcher, + Store: store, + Fetch: fetcher, + AllowPrivateNetworks: true, }) if len(warnings) != 0 { @@ -709,8 +710,9 @@ func TestResolveAIA_PKCS7Response(t *testing.T) { } warnings := ResolveAIA(context.Background(), ResolveAIAInput{ - Store: store, - Fetch: fetcher, + Store: store, + Fetch: fetcher, + AllowPrivateNetworks: true, }) if len(warnings) != 0 { diff --git a/internal/verify.go b/internal/verify.go index 26a160c3..824da201 100644 --- a/internal/verify.go +++ b/internal/verify.go @@ -16,17 +16,18 @@ import ( // VerifyInput holds the parsed certificate data and verification options. type VerifyInput struct { - Cert *x509.Certificate - Key crypto.PrivateKey - ExtraCerts []*x509.Certificate - CustomRoots []*x509.Certificate - CheckKeyMatch bool - CheckChain bool - ExpiryDuration time.Duration - TrustStore string - Verbose bool - CheckOCSP bool - CheckCRL bool + Cert *x509.Certificate + Key crypto.PrivateKey + ExtraCerts []*x509.Certificate + CustomRoots []*x509.Certificate + CheckKeyMatch bool + CheckChain bool + ExpiryDuration time.Duration + TrustStore string + Verbose bool + CheckOCSP bool + CheckCRL bool + AllowPrivateNetworks bool } // ChainCert holds display information for one certificate in the chain. @@ -143,6 +144,7 @@ func VerifyCert(ctx context.Context, input *VerifyInput) (*VerifyResult, error) opts.TrustStore = input.TrustStore opts.ExtraIntermediates = input.ExtraCerts opts.CustomRoots = input.CustomRoots + opts.AllowPrivateNetworks = input.AllowPrivateNetworks var bundleErr error bundle, bundleErr = certkit.Bundle(ctx, certkit.BundleInput{ Leaf: cert, @@ -171,7 +173,7 @@ func VerifyCert(ctx context.Context, input *VerifyInput) (*VerifyResult, error) } if issuer != nil { if input.CheckOCSP { - result.OCSP = checkVerifyOCSP(ctx, certkit.CheckOCSPInput{Cert: cert, Issuer: issuer}) + result.OCSP = checkVerifyOCSP(ctx, certkit.CheckOCSPInput{Cert: cert, Issuer: issuer, AllowPrivateNetworks: input.AllowPrivateNetworks}) if result.OCSP.Status == "revoked" { msg := "certificate is revoked (OCSP)" if result.OCSP.RevokedAt != nil { @@ -185,8 +187,9 @@ func VerifyCert(ctx context.Context, input *VerifyInput) (*VerifyResult, error) } if input.CheckCRL { result.CRL = certkit.CheckLeafCRL(ctx, certkit.CheckLeafCRLInput{ - Leaf: cert, - Issuer: issuer, + Leaf: cert, + Issuer: issuer, + AllowPrivateNetworks: input.AllowPrivateNetworks, }) if result.CRL.Status == "revoked" { result.Errors = append(result.Errors, fmt.Sprintf("certificate is revoked (CRL, %s)", result.CRL.Detail)) diff --git a/internal/verify_test.go b/internal/verify_test.go index cfa162a0..7748552f 100644 --- a/internal/verify_test.go +++ b/internal/verify_test.go @@ -1680,12 +1680,13 @@ func TestVerifyCert_RevocationBehavior(t *testing.T) { } result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckOCSP: tc.checkOCSP, - CheckCRL: tc.checkCRL, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckOCSP: tc.checkOCSP, + CheckCRL: tc.checkCRL, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -1816,13 +1817,14 @@ func TestVerifyCert_RevocationIssuerIntermediate(t *testing.T) { leaf.cert.CRLDistributionPoints = []string{strings.Replace(crlServer.URL, "127.0.0.1", "localhost", 1)} result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{root.cert}, - ExtraCerts: []*x509.Certificate{intermediate.cert}, - CheckOCSP: true, - CheckCRL: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{root.cert}, + ExtraCerts: []*x509.Certificate{intermediate.cert}, + CheckOCSP: true, + CheckCRL: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -1854,11 +1856,12 @@ func TestVerifyCert_RevocationWithoutChain(t *testing.T) { leaf := newRSALeaf(t, ca, "nochain.example.com", []string{"nochain.example.com"}, nil) result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckOCSP: true, - CheckCRL: true, - CheckChain: false, - TrustStore: "custom", + Cert: leaf.cert, + CheckOCSP: true, + CheckCRL: true, + CheckChain: false, + TrustStore: "custom", + AllowPrivateNetworks: true, CustomRoots: []*x509.Certificate{ ca.cert, }, @@ -2076,11 +2079,12 @@ func TestVerifyCert_OCSPStatus(t *testing.T) { defer cancel() } result, err := VerifyCert(ctx, &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckOCSP: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckOCSP: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -2140,11 +2144,12 @@ func TestVerifyCert_OCSPStatus_ECDSA(t *testing.T) { leaf.cert.OCSPServer = []string{strings.Replace(server.URL, "127.0.0.1", "localhost", 1)} result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckOCSP: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckOCSP: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -2326,11 +2331,12 @@ func TestVerifyCert_CRLStatus(t *testing.T) { defer cancel() } result, err := VerifyCert(ctx, &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckCRL: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckCRL: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -2386,11 +2392,12 @@ func TestVerifyCert_CRLStatus_ECDSA(t *testing.T) { leaf.cert.CRLDistributionPoints = []string{strings.Replace(server.URL, "127.0.0.1", "localhost", 1)} result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckCRL: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckCRL: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) @@ -2493,12 +2500,13 @@ func TestVerifyCert_RevocationCombined(t *testing.T) { leaf.cert.CRLDistributionPoints = []string{strings.Replace(crlServer.URL, "127.0.0.1", "localhost", 1)} result, err := VerifyCert(context.Background(), &VerifyInput{ - Cert: leaf.cert, - CheckChain: true, - TrustStore: "custom", - CustomRoots: []*x509.Certificate{ca.cert}, - CheckOCSP: true, - CheckCRL: true, + Cert: leaf.cert, + CheckChain: true, + TrustStore: "custom", + CustomRoots: []*x509.Certificate{ca.cert}, + CheckOCSP: true, + CheckCRL: true, + AllowPrivateNetworks: true, }) if err != nil { t.Fatal(err) diff --git a/ocsp.go b/ocsp.go index 8b9b35c7..a025bef3 100644 --- a/ocsp.go +++ b/ocsp.go @@ -18,6 +18,8 @@ type CheckOCSPInput struct { Cert *x509.Certificate // Issuer is the issuer certificate (used to build the OCSP request). Issuer *x509.Certificate + // AllowPrivateNetworks allows OCSP requests to private/internal endpoints. + AllowPrivateNetworks bool } // OCSPResult contains the OCSP response details. @@ -56,7 +58,7 @@ func CheckOCSP(ctx context.Context, input CheckOCSPInput) (*OCSPResult, error) { responderURL := input.Cert.OCSPServer[0] - if err := ValidateAIAURL(responderURL); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: responderURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return nil, fmt.Errorf("validating OCSP responder URL: %w", err) } @@ -78,7 +80,7 @@ func CheckOCSP(ctx context.Context, input CheckOCSPInput) (*OCSPResult, error) { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - if err := ValidateAIAURL(req.URL.String()); err != nil { + if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } return nil diff --git a/ocsp_test.go b/ocsp_test.go index 9072c271..aa1969e1 100644 --- a/ocsp_test.go +++ b/ocsp_test.go @@ -89,8 +89,9 @@ func TestCheckOCSP_MockResponse(t *testing.T) { defer cancel() result, err := CheckOCSP(ctx, CheckOCSPInput{ - Cert: leafCert, - Issuer: ca.Cert, + Cert: leafCert, + Issuer: ca.Cert, + AllowPrivateNetworks: true, }) if err != nil { t.Fatalf("CheckOCSP failed: %v", err) @@ -169,3 +170,25 @@ func TestFormatOCSPResult(t *testing.T) { } } } + +func TestCheckOCSP_PrivateEndpointBlockedByDefault(t *testing.T) { + t.Parallel() + + ca := generateTestCA(t, "OCSP Private Endpoint CA") + leaf := generateTestLeafCert(t, ca, withOCSPServer("http://localhost/ocsp")) + leafCert, err := x509.ParseCertificate(leaf.DER) + if err != nil { + t.Fatal(err) + } + + _, err = CheckOCSP(context.Background(), CheckOCSPInput{ + Cert: leafCert, + Issuer: ca.Cert, + }) + if err == nil { + t.Fatal("expected error for private OCSP endpoint") + } + if !strings.Contains(err.Error(), "validating OCSP responder URL") { + t.Fatalf("error = %q, want validating OCSP responder URL", err.Error()) + } +} From fe2a1e91ae97d4bfd8cd3e87ec57f340a5df94b8 Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 1 Mar 2026 18:43:38 -0500 Subject: [PATCH 2/5] fix(network): propagate SSRF validation deadlines and unblock inspect AIA opt-in --- CHANGELOG.md | 2 +- README.md | 7 ++-- bundle.go | 10 +++--- certkit_test.go | 75 ++++++++++++++++++++++++++------------- cmd/certkit/inspect.go | 10 ++++-- cmd/certkit/scan.go | 38 +++++++++++--------- connect.go | 4 +-- connect_test.go | 6 ++-- crl.go | 4 +-- internal/certstore/aia.go | 2 +- internal/inspect.go | 10 +++--- ocsp.go | 4 +-- 12 files changed, 106 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9f6d48..9b75ae29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Block DNS-rebind SSRF in AIA/OCSP/CRL fetches by validating DNS-resolved hostnames against private/internal address ranges by default, and add explicit `--allow-private-network` opt-in flags for internal PKI endpoints in `connect`, `verify`, `ocsp`, and `scan` ([#98]) +- Harden AIA/OCSP/CRL SSRF checks by validating DNS-resolved hostnames against private/internal address ranges by default, and add explicit `--allow-private-network` opt-in flags for internal PKI endpoints in `connect`, `verify`, `ocsp`, `scan`, and `inspect` ([#98]) - Prevent bundle export path traversal by sanitizing bundle folder names and enforcing safe output paths ([#87]) - Enforce size limits on input reads to avoid unbounded memory usage ([#87]) - Add SSRF validation (`ValidateAIAURL`) to OCSP responder URLs and CRL distribution point URLs — previously only AIA certificate URLs were validated ([#78]) diff --git a/README.md b/README.md index 35c500e2..fe14ecda 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,10 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always ### Inspect Flags -| Flag | Default | Description | -| ---------- | ------- | ------------------------- | -| `--format` | `text` | Output format: text, json | +| Flag | Default | Description | +| ------------------------- | ------- | ----------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | +| `--format` | `text` | Output format: text, json | ### Verify Flags diff --git a/bundle.go b/bundle.go index e7893fd5..5ff7ea2b 100644 --- a/bundle.go +++ b/bundle.go @@ -202,7 +202,7 @@ func ipBlockedForAIA(ip net.IP) error { // By default, it rejects non-HTTP(S) schemes plus literal and DNS-resolved // private/loopback/link-local/unspecified addresses to prevent SSRF and // DNS-rebind attacks. Set AllowPrivateNetworks to bypass IP restrictions. -func ValidateAIAURLWithOptions(input ValidateAIAURLInput) error { +func ValidateAIAURLWithOptions(ctx context.Context, input ValidateAIAURLInput) error { parsed, err := url.Parse(input.URL) if err != nil { return fmt.Errorf("parsing URL: %w", err) @@ -235,7 +235,7 @@ func ValidateAIAURLWithOptions(input ValidateAIAURLInput) error { lookup = defaultLookupIPAddresses } - resolveCtx, cancel := context.WithTimeout(context.Background(), aiaURLResolveTimeout) + resolveCtx, cancel := context.WithTimeout(ctx, aiaURLResolveTimeout) defer cancel() ips, err := lookup(resolveCtx, host) @@ -258,7 +258,7 @@ func ValidateAIAURLWithOptions(input ValidateAIAURLInput) error { // requests. It rejects non-HTTP(S) schemes plus literal and DNS-resolved // private/loopback/link-local/unspecified addresses. func ValidateAIAURL(rawURL string) error { - return ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: rawURL}) + return ValidateAIAURLWithOptions(context.Background(), ValidateAIAURLInput{URL: rawURL}) } // VerifyChainTrustInput holds parameters for VerifyChainTrust. @@ -426,7 +426,7 @@ func FetchAIACertificates(ctx context.Context, input FetchAIACertificatesInput) if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(req.Context(), ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } return nil @@ -445,7 +445,7 @@ func FetchAIACertificates(ctx context.Context, input FetchAIACertificatesInput) } seen[aiaURL] = true - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(ctx, ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { warnings = append(warnings, fmt.Sprintf("AIA URL rejected for %s: %v", aiaURL, err)) continue } diff --git a/certkit_test.go b/certkit_test.go index a605966d..9097cd16 100644 --- a/certkit_test.go +++ b/certkit_test.go @@ -15,6 +15,7 @@ import ( "encoding/asn1" "encoding/hex" "encoding/pem" + "errors" "fmt" "net" "slices" @@ -1777,37 +1778,38 @@ func TestValidateAIAURL(t *testing.T) { t.Parallel() tests := []struct { - name string - url string - wantErr bool - errSub string + name string + url string + allowPrivate bool + wantErr bool + errSub string }{ - {"valid public IPv4 http", "http://8.8.8.8/issuer.cer", false, ""}, - {"valid public IPv4 https", "https://8.8.8.8/issuer.cer", false, ""}, - {"ftp rejected", "ftp://ca.example.com/issuer.cer", true, "unsupported scheme"}, - {"file rejected", "file:///etc/passwd", true, "unsupported scheme"}, - {"empty scheme rejected", "://foo", true, "parsing URL"}, - {"missing hostname rejected", "https:///issuer.cer", true, "missing hostname"}, - {"loopback IPv4", "http://127.0.0.1/ca.cer", true, "loopback"}, - {"loopback IPv6", "http://[::1]/ca.cer", true, "loopback"}, - {"localhost hostname", "http://localhost/ca.cer", true, "resolved"}, - {"link-local IPv4", "http://169.254.1.1/ca.cer", true, "loopback, link-local, or unspecified"}, - {"unspecified IPv4", "http://0.0.0.0/ca.cer", true, "loopback, link-local, or unspecified"}, - {"unspecified IPv6", "http://[::]/ca.cer", true, "loopback, link-local, or unspecified"}, - {"private IPv6 ULA", "http://[fd12::1]/ca.cer", true, "blocked private"}, - {"private 10.x", "http://10.0.0.1/ca.cer", true, "blocked private"}, - {"private 172.16.x", "http://172.16.0.1/ca.cer", true, "blocked private"}, - {"private 192.168.x", "http://192.168.1.1/ca.cer", true, "blocked private"}, - {"CGN 100.64.x", "http://100.64.0.1/ca.cer", true, "blocked private"}, - {"allow private network option", "http://127.0.0.1/ca.cer", false, ""}, + {"valid public IPv4 http", "http://8.8.8.8/issuer.cer", false, false, ""}, + {"valid public IPv4 https", "https://8.8.8.8/issuer.cer", false, false, ""}, + {"ftp rejected", "ftp://ca.example.com/issuer.cer", false, true, "unsupported scheme"}, + {"file rejected", "file:///etc/passwd", false, true, "unsupported scheme"}, + {"empty scheme rejected", "://foo", false, true, "parsing URL"}, + {"missing hostname rejected", "https:///issuer.cer", false, true, "missing hostname"}, + {"loopback IPv4", "http://127.0.0.1/ca.cer", false, true, "loopback"}, + {"loopback IPv6", "http://[::1]/ca.cer", false, true, "loopback"}, + {"localhost hostname", "http://localhost/ca.cer", false, true, "resolved"}, + {"link-local IPv4", "http://169.254.1.1/ca.cer", false, true, "loopback, link-local, or unspecified"}, + {"unspecified IPv4", "http://0.0.0.0/ca.cer", false, true, "loopback, link-local, or unspecified"}, + {"unspecified IPv6", "http://[::]/ca.cer", false, true, "loopback, link-local, or unspecified"}, + {"private IPv6 ULA", "http://[fd12::1]/ca.cer", false, true, "blocked private"}, + {"private 10.x", "http://10.0.0.1/ca.cer", false, true, "blocked private"}, + {"private 172.16.x", "http://172.16.0.1/ca.cer", false, true, "blocked private"}, + {"private 192.168.x", "http://192.168.1.1/ca.cer", false, true, "blocked private"}, + {"CGN 100.64.x", "http://100.64.0.1/ca.cer", false, true, "blocked private"}, + {"allow private network option", "http://127.0.0.1/ca.cer", true, false, ""}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var err error - if tt.name == "allow private network option" { - err = ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: tt.url, AllowPrivateNetworks: true}) + if tt.allowPrivate { + err = ValidateAIAURLWithOptions(context.Background(), ValidateAIAURLInput{URL: tt.url, AllowPrivateNetworks: true}) } else { err = ValidateAIAURL(tt.url) } @@ -1892,7 +1894,7 @@ func TestValidateAIAURLWithOptions_HostnameResolution(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := ValidateAIAURLWithOptions(tt.input) + err := ValidateAIAURLWithOptions(context.Background(), tt.input) if tt.wantErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) @@ -1909,6 +1911,29 @@ func TestValidateAIAURLWithOptions_HostnameResolution(t *testing.T) { } } +func TestValidateAIAURLWithOptions_ContextDeadline(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + lookup := func(ctx context.Context, _ string) ([]net.IP, error) { + <-ctx.Done() + return nil, ctx.Err() + } + + err := ValidateAIAURLWithOptions(ctx, ValidateAIAURLInput{ + URL: "https://example.com/issuer.cer", + lookupIPAddresses: lookup, + }) + if err == nil { + t.Fatal("expected context deadline error") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want context.DeadlineExceeded", err) + } +} + func TestAlgorithmName(t *testing.T) { // WHY: KeyAlgorithmName and PublicKeyAlgorithmName produce display strings // for CLI output and JSON; wrong names would confuse users and break JSON diff --git a/cmd/certkit/inspect.go b/cmd/certkit/inspect.go index 4c6c2b07..11d366ad 100644 --- a/cmd/certkit/inspect.go +++ b/cmd/certkit/inspect.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "log/slog" "slices" @@ -10,6 +11,7 @@ import ( ) var inspectFormat string +var inspectAllowPrivateNetwork bool var inspectCmd = &cobra.Command{ Use: "inspect ", @@ -24,6 +26,7 @@ var inspectCmd = &cobra.Command{ func init() { inspectCmd.Flags().StringVar(&inspectFormat, "format", "text", "Output format: text, json") + inspectCmd.Flags().BoolVar(&inspectAllowPrivateNetwork, "allow-private-network", false, "Allow AIA fetches to private/internal endpoints") registerCompletion(inspectCmd, completionInput{"format", fixedCompletion("text", "json")}) } @@ -41,8 +44,11 @@ func runInspect(cmd *cobra.Command, args []string) error { // Resolve missing intermediates via AIA before trust annotation. results, aiaWarnings := internal.ResolveInspectAIA(cmd.Context(), internal.ResolveInspectAIAInput{ - Results: results, - Fetch: httpAIAFetcher, + Results: results, + AllowPrivateNetworks: inspectAllowPrivateNetwork, + Fetch: func(ctx context.Context, rawURL string) ([]byte, error) { + return fetchAIAURL(ctx, rawURL, inspectAllowPrivateNetwork) + }, }) for _, w := range aiaWarnings { slog.Warn("AIA resolution", "warning", w) diff --git a/cmd/certkit/scan.go b/cmd/certkit/scan.go index 1307bac6..d580937f 100644 --- a/cmd/certkit/scan.go +++ b/cmd/certkit/scan.go @@ -476,31 +476,32 @@ func printScanVerboseText(store *certstore.MemStore) { } } -// aiaHTTPClient is reused across AIA fetches to enable TCP connection reuse. +// newAIAHTTPClient creates an HTTP client for AIA fetches. // Redirects are limited to 3 and validated against SSRF rules. -var aiaHTTPClient = &http.Client{ - Timeout: 2 * time.Second, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 3 { - return fmt.Errorf("stopped after 3 redirects") - } - if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: scanAllowPrivateNetwork}); err != nil { - return fmt.Errorf("redirect blocked: %w", err) - } - return nil - }, +func newAIAHTTPClient(allowPrivateNetworks bool) *http.Client { + return &http.Client{ + Timeout: 2 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("stopped after 3 redirects") + } + if err := certkit.ValidateAIAURLWithOptions(req.Context(), certkit.ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: allowPrivateNetworks}); err != nil { + return fmt.Errorf("redirect blocked: %w", err) + } + return nil + }, + } } -// httpAIAFetcher fetches raw certificate bytes from a URL via HTTP. -func httpAIAFetcher(ctx context.Context, rawURL string) ([]byte, error) { - if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: rawURL, AllowPrivateNetworks: scanAllowPrivateNetwork}); err != nil { +func fetchAIAURL(ctx context.Context, rawURL string, allowPrivateNetworks bool) ([]byte, error) { + if err := certkit.ValidateAIAURLWithOptions(ctx, certkit.ValidateAIAURLInput{URL: rawURL, AllowPrivateNetworks: allowPrivateNetworks}); err != nil { return nil, fmt.Errorf("AIA URL rejected: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, fmt.Errorf("creating AIA request: %w", err) } - resp, err := aiaHTTPClient.Do(req) + resp, err := newAIAHTTPClient(allowPrivateNetworks).Do(req) if err != nil { return nil, fmt.Errorf("fetching AIA URL %s: %w", rawURL, err) } @@ -515,6 +516,11 @@ func httpAIAFetcher(ctx context.Context, rawURL string) ([]byte, error) { return data, nil } +// httpAIAFetcher fetches raw certificate bytes from a URL via HTTP. +func httpAIAFetcher(ctx context.Context, rawURL string) ([]byte, error) { + return fetchAIAURL(ctx, rawURL, scanAllowPrivateNetwork) +} + // formatDN formats a pkix.Name as a one-line distinguished name string // matching the OpenSSL one-line format (e.g. "CN=example.com, O=Acme, C=US"). func formatDN(name pkix.Name) string { diff --git a/connect.go b/connect.go index 677b06d4..b74c1f7b 100644 --- a/connect.go +++ b/connect.go @@ -365,7 +365,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err PeerChain: legacyResult.certificates, LegacyProbe: true, } - result.populate(connectCtx, input) + result.populate(ctx, input) result.Diagnostics = append(result.Diagnostics, ChainDiagnostic{ Check: "legacy-only", Status: "warn", @@ -397,7 +397,7 @@ func ConnectTLS(ctx context.Context, input ConnectTLSInput) (*ConnectResult, err TLSSCTs: state.SignedCertificateTimestamps, } - result.populate(connectCtx, input) + result.populate(ctx, input) return result, nil } diff --git a/connect_test.go b/connect_test.go index e34c29ef..850692b9 100644 --- a/connect_test.go +++ b/connect_test.go @@ -592,9 +592,9 @@ func TestConnectTLS_CancelledContext(t *testing.T) { } } -func TestConnectTLS_DefaultTimeoutWhenContextHasNoDeadline(t *testing.T) { - // WHY: ConnectTLS must apply a safe timeout when callers pass context.Background() - // so stalled handshakes do not block indefinitely. +func TestConnectTLS_UsesConnectTimeoutWhenContextHasNoDeadline(t *testing.T) { + // WHY: When callers pass a context without a deadline, ConnectTLS should + // honor ConnectTimeout to avoid hanging on stalled handshakes. t.Parallel() listener, err := net.Listen("tcp", "127.0.0.1:0") diff --git a/crl.go b/crl.go index 9b4be75c..ad357f87 100644 --- a/crl.go +++ b/crl.go @@ -46,7 +46,7 @@ type FetchCRLInput struct { // The response is limited to 10 MB. func FetchCRL(ctx context.Context, input FetchCRLInput) ([]byte, error) { if !input.AllowPrivateNetworks { - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: input.URL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(ctx, ValidateAIAURLInput{URL: input.URL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return nil, fmt.Errorf("validating CRL URL: %w", err) } } @@ -59,7 +59,7 @@ func FetchCRL(ctx context.Context, input FetchCRLInput) ([]byte, error) { return fmt.Errorf("stopped after %d redirects", maxRedirects) } if !input.AllowPrivateNetworks { - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(req.Context(), ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } } diff --git a/internal/certstore/aia.go b/internal/certstore/aia.go index b872dea5..4d0111c0 100644 --- a/internal/certstore/aia.go +++ b/internal/certstore/aia.go @@ -135,7 +135,7 @@ func ResolveAIA(ctx context.Context, input ResolveAIAInput) []string { } seen[aiaURL] = true - if err := certkit.ValidateAIAURLWithOptions(certkit.ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := certkit.ValidateAIAURLWithOptions(ctx, certkit.ValidateAIAURLInput{URL: aiaURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { warnings = append(warnings, fmt.Sprintf( "AIA URL rejected for %q: %v", rec.Cert.Subject.CommonName, err, diff --git a/internal/inspect.go b/internal/inspect.go index ab1806c3..108e1551 100644 --- a/internal/inspect.go +++ b/internal/inspect.go @@ -309,8 +309,9 @@ func privateKeySize(key any) string { // ResolveInspectAIAInput holds parameters for ResolveInspectAIA. type ResolveInspectAIAInput struct { - Results []InspectResult - Fetch certstore.AIAFetcher + Results []InspectResult + Fetch certstore.AIAFetcher + AllowPrivateNetworks bool } // ResolveInspectAIA fetches missing intermediate certificates via AIA for the @@ -340,8 +341,9 @@ func ResolveInspectAIA(ctx context.Context, input ResolveInspectAIAInput) ([]Ins } warnings := certstore.ResolveAIA(ctx, certstore.ResolveAIAInput{ - Store: store, - Fetch: input.Fetch, + Store: store, + Fetch: input.Fetch, + AllowPrivateNetworks: input.AllowPrivateNetworks, }) for _, rec := range store.AllCertsFlat() { diff --git a/ocsp.go b/ocsp.go index a025bef3..968d9922 100644 --- a/ocsp.go +++ b/ocsp.go @@ -58,7 +58,7 @@ func CheckOCSP(ctx context.Context, input CheckOCSPInput) (*OCSPResult, error) { responderURL := input.Cert.OCSPServer[0] - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: responderURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(ctx, ValidateAIAURLInput{URL: responderURL, AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return nil, fmt.Errorf("validating OCSP responder URL: %w", err) } @@ -80,7 +80,7 @@ func CheckOCSP(ctx context.Context, input CheckOCSPInput) (*OCSPResult, error) { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - if err := ValidateAIAURLWithOptions(ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { + if err := ValidateAIAURLWithOptions(req.Context(), ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil { return fmt.Errorf("redirect blocked: %w", err) } return nil From 0073502000a5af117254feb017b20664ae13efac Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 1 Mar 2026 19:00:38 -0500 Subject: [PATCH 3/5] fix(bundle): restore private-network opt-in for AIA chain fetches --- CHANGELOG.md | 2 +- README.md | 15 ++++++++------- cmd/certkit/bundle.go | 13 ++++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b75ae29..81606fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security -- Harden AIA/OCSP/CRL SSRF checks by validating DNS-resolved hostnames against private/internal address ranges by default, and add explicit `--allow-private-network` opt-in flags for internal PKI endpoints in `connect`, `verify`, `ocsp`, `scan`, and `inspect` ([#98]) +- Harden AIA/OCSP/CRL SSRF checks by validating DNS-resolved hostnames against private/internal address ranges by default, and add explicit `--allow-private-network` opt-in flags for internal PKI endpoints in `connect`, `verify`, `ocsp`, `scan`, `inspect`, and `bundle` ([#98]) - Prevent bundle export path traversal by sanitizing bundle folder names and enforcing safe output paths ([#87]) - Enforce size limits on input reads to avoid unbounded memory usage ([#87]) - Add SSRF validation (`ValidateAIAURL`) to OCSP responder URLs and CRL distribution point URLs — previously only AIA certificate URLs were validated ([#78]) diff --git a/README.md b/README.md index fe14ecda..464f94d4 100644 --- a/README.md +++ b/README.md @@ -172,13 +172,14 @@ Port defaults to 443 if not specified. OCSP revocation status is checked automat ### Bundle Flags -| Flag | Default | Description | -| ------------------ | ---------- | ---------------------------------------------- | -| `--force`, `-f` | `false` | Skip chain verification | -| `--format` | `pem` | Output format: pem, chain, fullchain, p12, jks | -| `--key` | | Private key file (PEM) | -| `--out-file`, `-o` | _(stdout)_ | Output file | -| `--trust-store` | `mozilla` | Trust store: system, mozilla | +| Flag | Default | Description | +| ------------------------- | ---------- | ----------------------------------------------- | +| `--allow-private-network` | `false` | Allow AIA fetches to private/internal endpoints | +| `--force`, `-f` | `false` | Skip chain verification | +| `--format` | `pem` | Output format: pem, chain, fullchain, p12, jks | +| `--key` | | Private key file (PEM) | +| `--out-file`, `-o` | _(stdout)_ | Output file | +| `--trust-store` | `mozilla` | Trust store: system, mozilla | ### Convert Flags diff --git a/cmd/certkit/bundle.go b/cmd/certkit/bundle.go index 5ceaea06..140b662a 100644 --- a/cmd/certkit/bundle.go +++ b/cmd/certkit/bundle.go @@ -17,11 +17,12 @@ import ( ) var ( - bundleKeyPath string - bundleOutFile string - bundleFormat string - bundleForce bool - bundleTrustStore string + bundleKeyPath string + bundleOutFile string + bundleFormat string + bundleForce bool + bundleAllowPrivateNetwork bool + bundleTrustStore string ) var bundleCmd = &cobra.Command{ @@ -49,6 +50,7 @@ func init() { bundleCmd.Flags().StringVarP(&bundleOutFile, "out-file", "o", "", "Output file") bundleCmd.Flags().StringVar(&bundleFormat, "format", "pem", "Output format: pem, chain, fullchain, p12, jks") bundleCmd.Flags().BoolVarP(&bundleForce, "force", "f", false, "Skip chain verification") + bundleCmd.Flags().BoolVar(&bundleAllowPrivateNetwork, "allow-private-network", false, "Allow AIA fetches to private/internal endpoints") bundleCmd.Flags().StringVar(&bundleTrustStore, "trust-store", "mozilla", "Trust store: system, mozilla") bundleCmd.Flags().Lookup("out-file").Annotations = map[string][]string{"readme_default": {"_(stdout)_"}} @@ -98,6 +100,7 @@ func runBundle(cmd *cobra.Command, args []string) error { opts := certkit.DefaultOptions() opts.TrustStore = bundleTrustStore opts.ExtraIntermediates = extraCerts + opts.AllowPrivateNetworks = bundleAllowPrivateNetwork if bundleForce { opts.Verify = false } From 7b2f4c731c4a28004f558e5308d2e565b7d86ca4 Mon Sep 17 00:00:00 2001 From: Daniel Wood Date: Sun, 1 Mar 2026 19:24:40 -0500 Subject: [PATCH 4/5] fix(network): address remaining PR feedback for inspect AIA handling --- CHANGELOG.md | 1 + cmd/certkit/inspect.go | 2 +- cmd/certkit/scan.go | 21 +++++++++++++-------- cmd/wasm/inspect.go | 12 +++++++++--- internal/certstore/aia.go | 2 +- web/public/app.js | 4 ++++ web/public/index.html | 6 ++++++ 7 files changed, 35 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81606fb8..171b78b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fix WASM `inspect` AIA resolution to expose an explicit private-network opt-in so internal PKI intermediates can still be fetched when needed ([#98]) - Apply a default 10-second timeout in `ConnectTLS` when callers provide a context without a deadline, preventing indefinite hangs during TCP/TLS connect and handshake operations ([#99]) - Fix verify JSON chain output to use `not_after` for consistency with other commands ([#87]) - Fix Certificate Transparency availability handling to preserve parsed SCT candidates when the log list cannot be loaded and mark them as unavailable instead of dropping them ([#86]) diff --git a/cmd/certkit/inspect.go b/cmd/certkit/inspect.go index 11d366ad..f91c1529 100644 --- a/cmd/certkit/inspect.go +++ b/cmd/certkit/inspect.go @@ -47,7 +47,7 @@ func runInspect(cmd *cobra.Command, args []string) error { Results: results, AllowPrivateNetworks: inspectAllowPrivateNetwork, Fetch: func(ctx context.Context, rawURL string) ([]byte, error) { - return fetchAIAURL(ctx, rawURL, inspectAllowPrivateNetwork) + return fetchAIAURL(ctx, fetchAIAURLInput{rawURL: rawURL, allowPrivateNetworks: inspectAllowPrivateNetwork}) }, }) for _, w := range aiaWarnings { diff --git a/cmd/certkit/scan.go b/cmd/certkit/scan.go index d580937f..2ed09f68 100644 --- a/cmd/certkit/scan.go +++ b/cmd/certkit/scan.go @@ -493,32 +493,37 @@ func newAIAHTTPClient(allowPrivateNetworks bool) *http.Client { } } -func fetchAIAURL(ctx context.Context, rawURL string, allowPrivateNetworks bool) ([]byte, error) { - if err := certkit.ValidateAIAURLWithOptions(ctx, certkit.ValidateAIAURLInput{URL: rawURL, AllowPrivateNetworks: allowPrivateNetworks}); err != nil { +type fetchAIAURLInput struct { + rawURL string + allowPrivateNetworks bool +} + +func fetchAIAURL(ctx context.Context, input fetchAIAURLInput) ([]byte, error) { + if err := certkit.ValidateAIAURLWithOptions(ctx, certkit.ValidateAIAURLInput{URL: input.rawURL, AllowPrivateNetworks: input.allowPrivateNetworks}); err != nil { return nil, fmt.Errorf("AIA URL rejected: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, input.rawURL, nil) if err != nil { return nil, fmt.Errorf("creating AIA request: %w", err) } - resp, err := newAIAHTTPClient(allowPrivateNetworks).Do(req) + resp, err := newAIAHTTPClient(input.allowPrivateNetworks).Do(req) if err != nil { - return nil, fmt.Errorf("fetching AIA URL %s: %w", rawURL, err) + return nil, fmt.Errorf("fetching AIA URL %s: %w", input.rawURL, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, rawURL) + return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, input.rawURL) } data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit if err != nil { - return nil, fmt.Errorf("reading AIA response from %s: %w", rawURL, err) + return nil, fmt.Errorf("reading AIA response from %s: %w", input.rawURL, err) } return data, nil } // httpAIAFetcher fetches raw certificate bytes from a URL via HTTP. func httpAIAFetcher(ctx context.Context, rawURL string) ([]byte, error) { - return fetchAIAURL(ctx, rawURL, scanAllowPrivateNetwork) + return fetchAIAURL(ctx, fetchAIAURLInput{rawURL: rawURL, allowPrivateNetworks: scanAllowPrivateNetwork}) } // formatDN formats a pkix.Name as a one-line distinguished name string diff --git a/cmd/wasm/inspect.go b/cmd/wasm/inspect.go index dc08dbaf..6eee6e81 100644 --- a/cmd/wasm/inspect.go +++ b/cmd/wasm/inspect.go @@ -17,7 +17,7 @@ import ( // inspectFiles performs stateless inspection of certificate, key, and CSR data. // Unlike addFiles, it does not accumulate into the global MemStore. -// JS signature: certkitInspect(files: Array<{name: string, data: Uint8Array}>, passwords: string) → Promise +// JS signature: certkitInspect(files: Array<{name: string, data: Uint8Array}>, passwords: string, allowPrivateNetwork?: boolean) → Promise func inspectFiles(_ js.Value, args []js.Value) any { if len(args) < 1 { return jsError("certkitInspect requires at least 1 argument") @@ -37,6 +37,11 @@ func inspectFiles(_ js.Value, args []js.Value) any { } passwords = certkit.DeduplicatePasswords(passwords) + allowPrivateNetworks := false + if len(args) >= 3 && args[2].Type() == js.TypeBoolean { + allowPrivateNetworks = args[2].Bool() + } + handler := js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { resolve := promiseArgs[0] reject := promiseArgs[1] @@ -74,8 +79,9 @@ func inspectFiles(_ js.Value, args []js.Value) any { // Resolve missing intermediates via AIA before trust annotation. allResults, aiaWarnings := internal.ResolveInspectAIA(ctx, internal.ResolveInspectAIAInput{ - Results: allResults, - Fetch: jsFetchURL, + Results: allResults, + Fetch: jsFetchURL, + AllowPrivateNetworks: allowPrivateNetworks, }) for _, w := range aiaWarnings { slog.Warn("AIA resolution", "warning", w) diff --git a/internal/certstore/aia.go b/internal/certstore/aia.go index 4d0111c0..b499233a 100644 --- a/internal/certstore/aia.go +++ b/internal/certstore/aia.go @@ -21,7 +21,7 @@ type ResolveAIAInput struct { MaxDepth int // 0 defaults to 5 Concurrency int // 0 defaults to 20; max parallel fetches per round OnProgress func(completed, total int) // optional; called after each cert's AIA URLs are processed - AllowPrivateNetworks bool + AllowPrivateNetworks bool // AllowPrivateNetworks allows AIA fetches to private/internal endpoints. } // HasUnresolvedIssuers reports whether any non-root certificate in the store diff --git a/web/public/app.js b/web/public/app.js index 0dc08ffa..4c19b8f0 100644 --- a/web/public/app.js +++ b/web/public/app.js @@ -29,6 +29,9 @@ const selectAll = document.getElementById("select-all"); const inspectDropZone = document.getElementById("inspect-drop-zone"); const inspectFileInput = document.getElementById("inspect-file-input"); const inspectPasswordsInput = document.getElementById("inspect-passwords"); +const inspectAllowPrivateNetwork = document.getElementById( + "inspect-allow-private-network", +); const inspectStatusBar = document.getElementById("inspect-status"); const inspectStatusText = document.getElementById("inspect-status-text"); const inspectResultsSection = document.getElementById("inspect-results"); @@ -424,6 +427,7 @@ async function inspectFileObjects(fileObjects) { const resultJSON = await certkitInspect( fileObjects, inspectPasswordsInput.value.trim(), + inspectAllowPrivateNetwork.checked, ); const results = JSON.parse(resultJSON); diff --git a/web/public/index.html b/web/public/index.html index a3960d9b..c9cf06cc 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -320,6 +320,12 @@

Warnings

placeholder="password1, password2, ..." /> +
+ +