Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Enforce bounded per-file and total upload limits in WASM `addFiles` and `inspect` ingestion paths to prevent unbounded memory growth ([#105])
- Enforce local CRL file size limits for `certkit crl` and shared CRL readers to reject oversized inputs early ([#105])
- 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` ([#108])
- 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])
Expand All @@ -99,6 +100,8 @@ 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 ([#108])
- 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 ([#108])
- Fix `scan` directory traversal boundaries and resilience: symlinks that point outside the scan root are skipped, max-file-size checks now apply to symlink targets and archive reads, and transient walk errors no longer prune unrelated files during traversal ([#91])
- Fix `scan --bundle-path` text/default output to print a useful post-export summary (certificate/key counts plus export path) while keeping JSON export output unchanged ([#95])
- Fix `scan` to fail fast when per-file processing or size-check `stat` calls fail during directory traversal, instead of logging and silently continuing ([#106])
Expand Down Expand Up @@ -962,6 +965,7 @@ 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
[#108]: https://github.com/sensiblebit/certkit/pull/108
[#91]: https://github.com/sensiblebit/certkit/pull/91
[#95]: https://github.com/sensiblebit/certkit/pull/95
[#106]: https://github.com/sensiblebit/certkit/pull/106
Expand Down
90 changes: 48 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,51 +131,55 @@ Common passwords (`""`, `"password"`, `"changeit"`, `"keypassword"`) are always
### Inspect Flags

<!-- certkit:flags:inspect -->
| 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 |
<!-- /certkit:flags -->

### Verify Flags

<!-- certkit:flags:verify -->
| 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 |
<!-- /certkit:flags -->

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).

### Connect Flags

<!-- certkit:flags:connect -->
| 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) |
<!-- /certkit:flags -->

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).

### Bundle Flags

<!-- certkit:flags:bundle -->
| 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 |
<!-- /certkit:flags -->

### Convert Flags
Expand Down Expand Up @@ -217,18 +221,19 @@ Input format is auto-detected.
### Scan Flags

<!-- certkit:flags:scan -->
| 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 |
<!-- /certkit:flags -->

### Keygen Flags
Expand Down Expand Up @@ -264,10 +269,11 @@ Exactly one of `--template`, `--from-cert`, or `--from-csr` is required.
### OCSP Flags

<!-- certkit:flags:ocsp -->
| 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 |
<!-- /certkit:flags -->

The OCSP responder URL is read from the certificate's AIA extension.
Expand Down
116 changes: 88 additions & 28 deletions bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@ 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{
"0.0.0.0/8", // RFC 791 "this network"
"10.0.0.0/8", // RFC 1918
"172.16.0.0/12", // RFC 1918
"192.168.0.0/16", // RFC 1918
Expand Down Expand Up @@ -166,23 +169,40 @@ 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 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 reduce SSRF risk. Set
// AllowPrivateNetworks to bypass IP restrictions. This check does not fully
// prevent DNS-rebind TOCTOU attacks between validation-time DNS and dial-time
// DNS.
func ValidateAIAURLWithOptions(ctx context.Context, input ValidateAIAURLInput) error {
parsed, err := url.Parse(input.URL)
if err != nil {
return fmt.Errorf("parsing URL: %w", err)
}
Expand All @@ -193,21 +213,56 @@ 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 {
if !aiaDNSResolutionAvailable() {
return nil
Comment on lines +234 to +235
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Block private hostnames when DNS validation is skipped

In ValidateAIAURLWithOptions, the !aiaDNSResolutionAvailable() branch returns success for any hostname, and the js build sets DNS resolution as unavailable. In GOOS=js (WASM), this means default private-network blocking is bypassed for hostname URLs like http://localhost/... (only literal IPs are checked), so AIA/OCSP/CRL fetches can still reach private/internal targets even when AllowPrivateNetworks is false and the UI/CLI opt-in is not enabled.

Useful? React with 👍 / 👎.

}
lookup = defaultLookupIPAddresses
}
for _, network := range privateNetworks {
if network.Contains(ip) {
return fmt.Errorf("blocked private address %s", host)

resolveCtx, cancel := context.WithTimeout(ctx, 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)
Comment thread
danielewood marked this conversation as resolved.
}
}

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(context.Background(), ValidateAIAURLInput{URL: rawURL})
}

// VerifyChainTrustInput holds parameters for VerifyChainTrust.
type VerifyChainTrustInput struct {
Cert *x509.Certificate
Expand Down Expand Up @@ -277,6 +332,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.
Expand Down Expand Up @@ -352,6 +409,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.
Expand All @@ -369,7 +428,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(req.Context(), ValidateAIAURLInput{URL: req.URL.String(), AllowPrivateNetworks: input.AllowPrivateNetworks}); err != nil {
return fmt.Errorf("redirect blocked: %w", err)
}
return nil
Expand All @@ -388,7 +447,7 @@ func FetchAIACertificates(ctx context.Context, input FetchAIACertificatesInput)
}
seen[aiaURL] = true

if err := ValidateAIAURL(aiaURL); 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
}
Expand Down Expand Up @@ -532,9 +591,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 {
Expand Down
16 changes: 16 additions & 0 deletions bundle_lookup_default.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !js

package certkit

import (
"context"
"net"
)

func defaultLookupIPAddresses(ctx context.Context, host string) ([]net.IP, error) {
return net.DefaultResolver.LookupIP(ctx, "ip", host)
}

func aiaDNSResolutionAvailable() bool {
return true
}
16 changes: 16 additions & 0 deletions bundle_lookup_js.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build js

package certkit

import (
"context"
"net"
)

func defaultLookupIPAddresses(_ context.Context, _ string) ([]net.IP, error) {
return nil, nil
}

func aiaDNSResolutionAvailable() bool {
return false
}
Loading