VYPR
Moderate severityNVD Advisory· Published Mar 11, 2026· Updated Mar 12, 2026

SSRF in Quill via unvalidated URL from Apple notarization log retrieval

CVE-2026-31959

Description

Quill provides simple mac binary signing and notarization from any platform. Quill before version v0.7.1 contains a Server-Side Request Forgery (SSRF) vulnerability when attempting to fetch the Apple notarization submission logs. Exploitation requires the ability to modify API responses from Apple's notarization service, which is not possible under standard network conditions due to HTTPS with proper TLS certificate validation; however, environments with TLS-intercepting proxies (common in corporate networks), compromised certificate authorities, or other trust boundary violations are at risk. When retrieving submission logs, Quill fetches a URL provided in the API response without validating that the scheme is https or that the host does not point to a local or multicast IP address. An attacker who can tamper with the response can supply an arbitrary URL, causing the Quill client to issue HTTP or HTTPS requests to attacker-controlled or internal network destinations. This could lead to exfiltration of sensitive data such as cloud provider credentials or internal service responses. Both the Quill CLI and library are affected when used to retrieve notarization submission logs. This vulnerability is fixed in 0.7.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Quill before v0.7.1 has an SSRF vulnerability allowing an attacker who can tamper with Apple notarization responses to force requests to arbitrary internal or external hosts.

Quill, a tool for macOS binary signing and notarization, contains a Server-Side Request Forgery (SSRF) vulnerability in versions prior to v0.7.1. The flaw occurs when Quill fetches notarization submission logs from Apple's service. The tool [1] constructs a request to a URL obtained from the API response without validating that the scheme is https or that the host does not point to a local, loopback, or multicast IP address. An attacker who can modify the API response (e.g., via a TLS-intercepting proxy in a corporate network or a compromised certificate authority) can supply an arbitrary URL [1][3].

Exploitation requires breaking the HTTPS trust chain with Apple's notarization service, which is not possible under standard network conditions due to proper TLS certificate validation. However, environments using TLS-intercepting proxies, compromised CAs, or other trust boundary violations are at risk [1][3]. In such scenarios, the attacker can provide a malicious URL in the API response, causing the Quill client to issue HTTP or HTTPS requests to attacker-controlled endpoints or internal network destinations [1].

The impact includes potential exfiltration of sensitive data such as cloud provider credentials (e.g., from cloud metadata services) or internal service responses [1][3]. Both the Quill CLI and library are affected when used to retrieve notarization submission logs [1].

The vulnerability is fixed in version v0.7.1. The fix [3][4] introduces URL validation that restricts allowed schemes to https and blocks IP addresses, localhost, and private/multicast ranges. Trusted domains such as *.apple.com and Apple's S3 bucket are allowed; all other domains generate a warning. Users should update to v0.7.1 or later [1][4].

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/anchore/quillGo
< 0.7.10.7.1

Affected products

2
  • Slab/Quillllm-fuzzy
    Range: <0.7.1
  • anchore/quillv5
    Range: < 0.7.1

Patches

1
e41d66a517c2

validate developer log URL requests (#680)

https://github.com/anchore/quillAlex GoodmanMar 10, 2026via ghsa
8 files changed · +800 19
  • .gitignore+1 0 modified
    @@ -32,6 +32,7 @@ CHANGELOG.md
     coverage.txt
     bin/
     cmd/quill/quill
    +generate
     
     # Binaries for programs and plugins
     *.exe
    
  • internal/urlvalidate/validate.go+158 0 added
    @@ -0,0 +1,158 @@
    +// Package urlvalidate provides URL validation for Apple notarization service responses.
    +//
    +// The developerLogUrl field comes from appstoreconnect.apple.com over HTTPS. Intercepting
    +// this response requires a compromised CA or TLS inspection proxy. The main risk we guard
    +// against is requests to internal services (localhost, private IPs) and cloud metadata
    +// endpoints (169.254.169.254).
    +//
    +// We use a three-tier validation approach:
    +//   - Known domains (apple.com, Apple's S3 bucket): allowed
    +//   - IPs and localhost: blocked
    +//   - Unknown domains: allowed with a warning logged
    +//
    +// This allows quill to keep working if Apple changes their infrastructure (e.g., new S3
    +// bucket, new CDN) while alerting users to investigate.
    +//
    +// We chose domain validation over certificate validation because:
    +//   - Domain checks happen before any connection; cert checks require connecting first
    +//   - Apple serves logs from S3, which has Amazon certificates, not Apple certificates
    +//   - Certificate pinning is brittle (Chrome removed HPKP for this reason)
    +//   - Certificate org fields are not reliable (anyone can register "Apple LLC")
    +package urlvalidate
    +
    +import (
    +	"fmt"
    +	"net"
    +	"net/url"
    +	"strings"
    +)
    +
    +// Config holds the configuration for URL validation.
    +type Config struct {
    +	TrustedDomains []string
    +	AllowedSchemes []string
    +}
    +
    +// DefaultConfig returns the default configuration for production use.
    +func DefaultConfig() Config {
    +	return Config{
    +		TrustedDomains: []string{
    +			".apple.com",
    +			// Apple's notary v2 API returns pre-signed S3 URLs for developer logs
    +			"notary-artifacts-prod.s3.amazonaws.com",
    +		},
    +		AllowedSchemes: []string{"https"},
    +	}
    +}
    +
    +// Validator validates URLs for fetching Apple resources.
    +type Validator struct {
    +	config Config
    +}
    +
    +// New creates a new Validator with the given configuration.
    +func New(cfg Config) *Validator {
    +	return &Validator{config: cfg}
    +}
    +
    +// Validate validates a URL for fetching Apple resources using a three-tier approach:
    +//  1. allowlist: Known trusted domains (apple.com, Apple's S3 bucket) - allowed silently
    +//  2. denylist: Known dangerous targets (IPs, localhost, metadata endpoints) - rejected with error
    +//  3. unknown: Other domains - allowed but returns a warning message for logging
    +//
    +// Returns:
    +//   - warning: non-empty if the URL is allowed but from an unexpected host (should be logged)
    +//   - error: non-nil if the URL is denied (should not be fetched)
    +func (v *Validator) Validate(rawURL string) (warning string, err error) {
    +	if rawURL == "" {
    +		return "", fmt.Errorf("URL is empty")
    +	}
    +
    +	parsed, err := url.Parse(rawURL)
    +	if err != nil {
    +		return "", fmt.Errorf("invalid URL: %w", err)
    +	}
    +
    +	// require allowed scheme (https in production, http can be added for tests)
    +	schemeAllowed := false
    +	for _, scheme := range v.config.AllowedSchemes {
    +		if parsed.Scheme == scheme {
    +			schemeAllowed = true
    +			break
    +		}
    +	}
    +	if !schemeAllowed {
    +		return "", fmt.Errorf("URL scheme must be https, got %q", parsed.Scheme)
    +	}
    +
    +	// url.Hostname() properly extracts the host, handling ports and IPv6 addresses.
    +	host := strings.ToLower(parsed.Hostname())
    +	if host == "" {
    +		return "", fmt.Errorf("URL has no hostname")
    +	}
    +
    +	// tier 1: check allowlist (known trusted domains)
    +	if v.isTrustedDomain(host) {
    +		return "", nil
    +	}
    +
    +	// tier 2: check denylist (dangerous targets)
    +	if reason := isDeniedHost(host); reason != "" {
    +		return "", fmt.Errorf("URL host %q is not allowed: %s", host, reason)
    +	}
    +
    +	// tier 3: unknown domain - allow but warn
    +	return fmt.Sprintf("unexpected host %q for developer log URL; this may indicate Apple has changed their infrastructure or a potential security issue", host), nil
    +}
    +
    +// isTrustedDomain checks if the host matches any trusted domain pattern.
    +func (v *Validator) isTrustedDomain(host string) bool {
    +	for _, domain := range v.config.TrustedDomains {
    +		baseDomain := strings.TrimPrefix(domain, ".")
    +		// allow exact match (e.g., "apple.com") or subdomain match (e.g., "developer.apple.com")
    +		if host == baseDomain || strings.HasSuffix(host, domain) {
    +			return true
    +		}
    +	}
    +	return false
    +}
    +
    +// isDeniedHost checks if the host is a known dangerous target.
    +// Returns a reason string if denied, empty string if not denied.
    +func isDeniedHost(host string) string {
    +	// check for IP addresses (all IPs are denied to prevent SSRF to internal services)
    +	if ip := net.ParseIP(host); ip != nil {
    +		if isLoopback(ip) {
    +			return "loopback addresses are not allowed"
    +		}
    +		if isPrivate(ip) {
    +			return "private network addresses are not allowed"
    +		}
    +		if isLinkLocal(ip) {
    +			return "link-local addresses are not allowed (includes cloud metadata endpoints)"
    +		}
    +		// deny all other IPs as well - legitimate services use domain names
    +		return "IP addresses are not allowed; expected a domain name"
    +	}
    +
    +	// check for localhost variations
    +	if host == "localhost" || strings.HasSuffix(host, ".localhost") {
    +		return "localhost is not allowed"
    +	}
    +
    +	return ""
    +}
    +
    +func isLoopback(ip net.IP) bool {
    +	return ip.IsLoopback()
    +}
    +
    +func isPrivate(ip net.IP) bool {
    +	return ip.IsPrivate()
    +}
    +
    +func isLinkLocal(ip net.IP) bool {
    +	// covers IPv4 link-local (169.254.x.x) which includes AWS/cloud metadata (169.254.169.254)
    +	// and IPv6 link-local (fe80::/10)
    +	return ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
    +}
    
  • internal/urlvalidate/validate_test.go+320 0 added
    @@ -0,0 +1,320 @@
    +package urlvalidate
    +
    +import (
    +	"testing"
    +
    +	"github.com/stretchr/testify/assert"
    +	"github.com/stretchr/testify/require"
    +)
    +
    +// defaultValidator creates a validator with default production config for testing.
    +func defaultValidator() *Validator {
    +	return New(DefaultConfig())
    +}
    +
    +func TestValidate(t *testing.T) {
    +	v := defaultValidator()
    +
    +	tests := []struct {
    +		name        string
    +		url         string
    +		wantWarning bool
    +		wantErr     require.ErrorAssertionFunc
    +	}{
    +		// tier 1: allowlisted domains (no warning, no error)
    +		{
    +			name: "valid apple.com URL",
    +			url:  "https://apple.com/logs/12345",
    +		},
    +		{
    +			name: "valid developer.apple.com URL",
    +			url:  "https://developer.apple.com/logs/12345",
    +		},
    +		{
    +			name: "valid cdn.notary.apple.com URL",
    +			url:  "https://cdn.notary.apple.com/logs/12345.json",
    +		},
    +		{
    +			name: "valid URL with query params",
    +			url:  "https://notary.apple.com/logs?id=12345&format=json",
    +		},
    +		{
    +			name: "valid URL with port",
    +			url:  "https://developer.apple.com:443/logs/12345",
    +		},
    +		{
    +			name: "valid www.apple.com URL",
    +			url:  "https://www.apple.com/certificateauthority/",
    +		},
    +		{
    +			name: "valid Apple notary S3 bucket URL",
    +			url:  "https://notary-artifacts-prod.s3.amazonaws.com/prod/abc123/developer_log.json?X-Amz-Security-Token=xyz",
    +		},
    +
    +		// tier 2: denylisted hosts (error)
    +		{
    +			name:    "localhost rejected",
    +			url:     "https://localhost/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "sub.localhost rejected",
    +			url:     "https://sub.localhost/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "127.0.0.1 rejected",
    +			url:     "https://127.0.0.1/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "AWS metadata endpoint rejected",
    +			url:     "https://169.254.169.254/latest/meta-data",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "IPv6 localhost rejected",
    +			url:     "https://[::1]/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "internal IP 192.168.x rejected",
    +			url:     "https://192.168.1.1/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "internal IP 10.x rejected",
    +			url:     "https://10.0.0.1/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "internal IP 172.16.x rejected",
    +			url:     "https://172.16.0.1/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "public IP also rejected",
    +			url:     "https://8.8.8.8/dns",
    +			wantErr: require.Error,
    +		},
    +
    +		// tier 3: unknown domains (allowed with warning)
    +		{
    +			name:        "unknown domain allowed with warning",
    +			url:         "https://example.com/logs",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "other S3 bucket allowed with warning",
    +			url:         "https://other-bucket.s3.amazonaws.com/data",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "S3 regional bucket allowed with warning",
    +			url:         "https://bucket.s3.us-east-1.amazonaws.com/data",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "random CDN allowed with warning",
    +			url:         "https://cdn.example.com/logs/12345",
    +			wantWarning: true,
    +		},
    +
    +		// invalid scheme (error regardless of host)
    +		{
    +			name:    "http scheme rejected",
    +			url:     "http://apple.com/logs/12345",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "ftp scheme rejected",
    +			url:     "ftp://apple.com/logs/12345",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "file scheme rejected",
    +			url:     "file:///etc/passwd",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "javascript scheme rejected",
    +			url:     "javascript:alert(1)",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "data scheme rejected",
    +			url:     "data:text/html,<script>alert(1)</script>",
    +			wantErr: require.Error,
    +		},
    +
    +		// IPv4-mapped IPv6 addresses (should be denied)
    +		{
    +			name:    "IPv4-mapped loopback rejected",
    +			url:     "https://[::ffff:127.0.0.1]/admin",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "IPv4-mapped metadata endpoint rejected",
    +			url:     "https://[::ffff:169.254.169.254]/latest/meta-data",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "IPv4-mapped private IP rejected",
    +			url:     "https://[::ffff:192.168.1.1]/admin",
    +			wantErr: require.Error,
    +		},
    +
    +		// octal/hex IP notation (Go's net.ParseIP doesn't parse these, so they're
    +		// treated as domain names and allowed with warning - they'd fail DNS anyway)
    +		{
    +			name:        "octal IP notation allowed with warning (not parsed as IP)",
    +			url:         "https://0177.0.0.1/admin",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "hex IP notation allowed with warning (not parsed as IP)",
    +			url:         "https://0x7f.0.0.1/admin",
    +			wantWarning: true,
    +		},
    +
    +		// SSRF bypass attempts (should be denied or warned)
    +		{
    +			name:        "userinfo attack: apple.com@evil.com correctly identifies evil.com as host",
    +			url:         "https://apple.com@evil.com/logs",
    +			wantWarning: true, // evil.com is the actual host, allowed with warning
    +		},
    +		{
    +			name:        "apple.com.evil.com allowed with warning (not actually apple)",
    +			url:         "https://apple.com.evil.com/logs",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "evilapple.com allowed with warning",
    +			url:         "https://evilapple.com/logs",
    +			wantWarning: true,
    +		},
    +		{
    +			name:        "similar domain allowed with warning (apple.com.attacker.com)",
    +			url:         "https://apple.com.attacker.com/logs",
    +			wantWarning: true,
    +		},
    +
    +		// edge cases (error)
    +		{
    +			name:    "empty URL rejected",
    +			url:     "",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "URL with no hostname rejected",
    +			url:     "https:///path",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "malformed URL rejected",
    +			url:     "://invalid",
    +			wantErr: require.Error,
    +		},
    +		{
    +			name:    "completely invalid URL rejected",
    +			url:     "not-a-url",
    +			wantErr: require.Error,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			if tt.wantErr == nil {
    +				tt.wantErr = require.NoError
    +			}
    +
    +			warning, err := v.Validate(tt.url)
    +			tt.wantErr(t, err)
    +
    +			if err != nil {
    +				return
    +			}
    +
    +			if tt.wantWarning {
    +				assert.NotEmpty(t, warning, "expected a warning for unknown host")
    +				assert.Contains(t, warning, "unexpected host")
    +			} else {
    +				assert.Empty(t, warning, "expected no warning for trusted host")
    +			}
    +		})
    +	}
    +}
    +
    +func TestValidate_CustomDomains(t *testing.T) {
    +	// create a validator with an additional trusted domain
    +	cfg := DefaultConfig()
    +	cfg.TrustedDomains = append(cfg.TrustedDomains, "test.local")
    +	v := New(cfg)
    +
    +	// now test.local should be allowed without warning
    +	warning, err := v.Validate("https://test.local/logs")
    +	require.NoError(t, err)
    +	assert.Empty(t, warning)
    +
    +	// but other untrusted domains should still warn
    +	warning, err = v.Validate("https://other.local/logs")
    +	require.NoError(t, err)
    +	assert.NotEmpty(t, warning)
    +
    +	// and IPs should still be denied
    +	_, err = v.Validate("https://127.0.0.1/logs")
    +	require.Error(t, err)
    +}
    +
    +func TestIsDeniedHost(t *testing.T) {
    +	tests := []struct {
    +		name       string
    +		host       string
    +		wantDenied bool
    +	}{
    +		// loopback
    +		{name: "localhost", host: "localhost", wantDenied: true},
    +		{name: "sub.localhost", host: "sub.localhost", wantDenied: true},
    +		{name: "127.0.0.1", host: "127.0.0.1", wantDenied: true},
    +		{name: "127.0.0.2", host: "127.0.0.2", wantDenied: true},
    +		{name: "::1", host: "::1", wantDenied: true},
    +
    +		// private ranges
    +		{name: "10.0.0.1", host: "10.0.0.1", wantDenied: true},
    +		{name: "10.255.255.255", host: "10.255.255.255", wantDenied: true},
    +		{name: "172.16.0.1", host: "172.16.0.1", wantDenied: true},
    +		{name: "172.31.255.255", host: "172.31.255.255", wantDenied: true},
    +		{name: "192.168.0.1", host: "192.168.0.1", wantDenied: true},
    +		{name: "192.168.255.255", host: "192.168.255.255", wantDenied: true},
    +
    +		// link-local (cloud metadata)
    +		{name: "169.254.169.254", host: "169.254.169.254", wantDenied: true},
    +		{name: "169.254.0.1", host: "169.254.0.1", wantDenied: true},
    +
    +		// IPv4-mapped IPv6 addresses
    +		{name: "::ffff:127.0.0.1", host: "::ffff:127.0.0.1", wantDenied: true},
    +		{name: "::ffff:10.0.0.1", host: "::ffff:10.0.0.1", wantDenied: true},
    +		{name: "::ffff:192.168.1.1", host: "::ffff:192.168.1.1", wantDenied: true},
    +		{name: "::ffff:169.254.169.254", host: "::ffff:169.254.169.254", wantDenied: true},
    +
    +		// public IPs (also denied - we only want domain names)
    +		{name: "8.8.8.8", host: "8.8.8.8", wantDenied: true},
    +		{name: "1.1.1.1", host: "1.1.1.1", wantDenied: true},
    +
    +		// domain names (not denied by isDeniedHost)
    +		{name: "example.com", host: "example.com", wantDenied: false},
    +		{name: "apple.com", host: "apple.com", wantDenied: false},
    +		{name: "s3.amazonaws.com", host: "s3.amazonaws.com", wantDenied: false},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			reason := isDeniedHost(tt.host)
    +			if tt.wantDenied {
    +				assert.NotEmpty(t, reason, "expected host to be denied")
    +			} else {
    +				assert.Empty(t, reason, "expected host to not be denied")
    +			}
    +		})
    +	}
    +}
    
  • quill/notary/api_client.go+48 3 modified
    @@ -7,6 +7,7 @@ import (
     	"fmt"
     	"io"
     	"net/http"
    +	"net/url"
     	"path"
     	"strings"
     	"sync/atomic"
    @@ -19,6 +20,8 @@ import (
     	"github.com/aws/aws-sdk-go-v2/service/s3"
     
     	"github.com/anchore/quill/internal/log"
    +	"github.com/anchore/quill/internal/redact"
    +	"github.com/anchore/quill/internal/urlvalidate"
     )
     
     type api interface {
    @@ -34,9 +37,19 @@ type APIClient struct {
     	api  string
     }
     
    +// NewAPIClient creates a new APIClient with the default URL validator configuration.
     func NewAPIClient(token string, httpTimeout time.Duration) *APIClient {
    +	return NewAPIClientWithValidator(token, httpTimeout, nil)
    +}
    +
    +// NewAPIClientWithValidator creates a new APIClient with a custom URL validator.
    +// If validator is nil, a default validator with production settings will be used.
    +func NewAPIClientWithValidator(token string, httpTimeout time.Duration, validator *urlvalidate.Validator) *APIClient {
    +	if validator == nil {
    +		validator = urlvalidate.New(urlvalidate.DefaultConfig())
    +	}
     	return &APIClient{
    -		http: newHTTPClient(token, httpTimeout),
    +		http: newHTTPClient(token, httpTimeout, validator),
     		api:  "https://appstoreconnect.apple.com/notary/v2/submissions",
     	}
     }
    @@ -67,6 +80,9 @@ func (s APIClient) uploadBinary(ctx context.Context, response submissionResponse
     	attrs := response.Data.Attributes
     	log.WithFields("bucket", attrs.Bucket, "object", attrs.Object).Trace("uploading binary to S3")
     
    +	// there is currently no path that would log these values, but let the redactor know about them just in case
    +	redact.Add(attrs.AwsAccessKeyID, attrs.AwsSecretAccessKey, attrs.AwsSessionToken)
    +
     	// create AWS config with static credentials
     	cfg, err := config.LoadDefaultConfig(ctx,
     		config.WithRegion("us-west-2"),
    @@ -142,8 +158,10 @@ func (s APIClient) submissionLogs(ctx context.Context, id string) (string, error
     		return "", fmt.Errorf("unable to decode log metadata response with ID=%s: %w", id, err)
     	}
     
    -	// note: we are not using the custom API client here since we don't need the token
    -	logsResp, err := http.Get(resp.Data.Attributes.DeveloperLogURL)
    +	redactPresignedURLParams(resp.Data.Attributes.DeveloperLogURL)
    +
    +	// fetch logs without auth header (it's a presigned URL with its own auth)
    +	logsResp, err := s.http.getUnauthenticated(ctx, resp.Data.Attributes.DeveloperLogURL)
     	contents, err := s.handleResponse(logsResp, err)
     	if err != nil {
     		return "", fmt.Errorf("unable to fetch log destination with ID=%s: %w", id, err)
    @@ -195,6 +213,33 @@ func (r *monitoredReader) Seek(offset int64, whence int) (int64, error) {
     	return r.reader.Seek(offset, whence)
     }
     
    +func redactPresignedURLParams(rawURL string) {
    +	u, err := url.Parse(rawURL)
    +	if err != nil {
    +		return
    +	}
    +
    +	// check both v2 and v4 signature parameter names (case-sensitive in query strings)
    +	params := []string{
    +		"AWSAccessKeyId",       // v2 signature
    +		"Signature",            // v2 signature
    +		"x-amz-security-token", // v2 signature
    +		"X-Amz-Security-Token", // v4 signature
    +		"X-Amz-Signature",      // v4 signature
    +		"X-Amz-Credential",     // v4 signature
    +	}
    +
    +	for _, p := range params {
    +		if v := u.Query().Get(p); v != "" {
    +			// add both decoded and URL-encoded versions since URLs may be logged either way
    +			redact.Add(v)
    +			if encoded := url.QueryEscape(v); encoded != v {
    +				redact.Add(encoded)
    +			}
    +		}
    +	}
    +}
    +
     func joinURL(base string, paths ...string) string {
     	p := path.Join(paths...)
     	return fmt.Sprintf("%s/%s", strings.TrimRight(base, "/"), strings.TrimLeft(p, "/"))
    
  • quill/notary/api_client_test.go+74 3 modified
    @@ -11,6 +11,11 @@ import (
     	"github.com/stretchr/testify/require"
     )
     
    +// newTestAPIClient creates an APIClient configured for test servers (http + 127.0.0.1).
    +func newTestAPIClient(token string, timeout time.Duration) *APIClient {
    +	return NewAPIClientWithValidator(token, timeout, testValidator())
    +}
    +
     func Test_apiClient_submissionRequest(t *testing.T) {
     	expected := submissionResponse{
     		Data: submissionResponseData{
    @@ -38,7 +43,7 @@ func Test_apiClient_submissionRequest(t *testing.T) {
     	s := httptest.NewServer(mux)
     	defer s.Close()
     
    -	c := NewAPIClient("the-token", time.Second*3)
    +	c := newTestAPIClient("the-token", time.Second*3)
     	c.api = s.URL
     
     	actual, err := c.submissionRequest(context.Background(), submissionRequest{
    @@ -51,6 +56,7 @@ func Test_apiClient_submissionRequest(t *testing.T) {
     }
     
     func Test_apiClient_submissionStatusRequest(t *testing.T) {
    +
     	id := "the-id"
     	expected := submissionStatusResponse{
     		Data: submissionStatusResponseData{
    @@ -76,7 +82,7 @@ func Test_apiClient_submissionStatusRequest(t *testing.T) {
     	s := httptest.NewServer(mux)
     	defer s.Close()
     
    -	c := NewAPIClient("the-token", time.Second*3)
    +	c := newTestAPIClient("the-token", time.Second*3)
     	c.api = s.URL
     
     	actual, err := c.submissionStatusRequest(context.Background(), id)
    @@ -90,6 +96,7 @@ func Test_apiClient_submissionStatusRequest(t *testing.T) {
     }
     
     func Test_apiClient_submissionLogs(t *testing.T) {
    +
     	id := "the-id"
     	expected := "the-logs"
     	expectedLogResponse := submissionLogsResponse{
    @@ -116,11 +123,75 @@ func Test_apiClient_submissionLogs(t *testing.T) {
     	expectedLogResponse.Data.Attributes.DeveloperLogURL = s.URL + "/place-where-the-logs-are"
     	defer s.Close()
     
    -	c := NewAPIClient("the-token", time.Second*3)
    +	c := newTestAPIClient("the-token", time.Second*3)
     	c.api = s.URL
     
     	actual, err := c.submissionLogs(context.Background(), id)
     	require.NoError(t, err)
     	require.NotNil(t, actual)
     	require.Equal(t, expected, actual)
     }
    +
    +func Test_apiClient_submissionLogs_rejectsDeniedURLs(t *testing.T) {
    +	// tests for URLs that should be outright rejected (tier 2: denylist)
    +	// note: http and 127.0.0.1 are allowed for test server, so we test other blocked values
    +	tests := []struct {
    +		name   string
    +		logURL string
    +	}{
    +		{
    +			name:   "rejects localhost",
    +			logURL: "https://localhost/logs",
    +		},
    +		{
    +			name:   "rejects loopback IP",
    +			logURL: "https://127.0.0.2/logs", // 127.0.0.2 is loopback but not in test allowlist
    +		},
    +		{
    +			name:   "rejects AWS metadata endpoint",
    +			logURL: "https://169.254.169.254/latest/meta-data",
    +		},
    +		{
    +			name:   "rejects private IP 10.x",
    +			logURL: "https://10.0.0.1/admin",
    +		},
    +		{
    +			name:   "rejects private IP 192.168.x",
    +			logURL: "https://192.168.1.1/admin",
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			id := "the-id"
    +			expectedLogResponse := submissionLogsResponse{
    +				Data: submissionLogsResponseData{
    +					submissionResponseDescriptor: submissionResponseDescriptor{
    +						Type: "the-ty",
    +						ID:   id,
    +					},
    +					Attributes: submissionLogsResponseAttributes{
    +						DeveloperLogURL: tt.logURL,
    +					},
    +				},
    +			}
    +
    +			mux := http.NewServeMux()
    +			mux.HandleFunc("/"+id+"/logs", func(w http.ResponseWriter, r *http.Request) {
    +				by, err := json.Marshal(expectedLogResponse)
    +				require.NoError(t, err)
    +				w.Write(by)
    +			})
    +
    +			s := httptest.NewServer(mux)
    +			defer s.Close()
    +
    +			c := newTestAPIClient("the-token", time.Second*3)
    +			c.api = s.URL
    +
    +			_, err := c.submissionLogs(context.Background(), id)
    +			require.Error(t, err)
    +			require.Contains(t, err.Error(), "URL validation failed")
    +		})
    +	}
    +}
    
  • quill/notary/http_client.go+54 5 modified
    @@ -8,26 +8,65 @@ import (
     	"time"
     
     	"github.com/anchore/quill/internal/log"
    +	"github.com/anchore/quill/internal/urlvalidate"
     )
     
     type httpClient struct {
    -	client *http.Client
    -	token  string
    +	client    *http.Client
    +	token     string
    +	validator *urlvalidate.Validator
     }
     
    -func newHTTPClient(token string, httpTimeout time.Duration) *httpClient {
    +func newHTTPClient(token string, httpTimeout time.Duration, validator *urlvalidate.Validator) *httpClient {
     	if httpTimeout == 0 {
     		httpTimeout = time.Second * 30
     	}
     
     	return &httpClient{
     		client: &http.Client{
     			Timeout: httpTimeout,
    +			// validate redirects to prevent SSRF attacks
    +			CheckRedirect: func(req *http.Request, via []*http.Request) error {
    +				warning, err := validator.Validate(req.URL.String())
    +				if err != nil {
    +					return fmt.Errorf("redirect to untrusted URL: %w", err)
    +				}
    +				if warning != "" {
    +					log.Warnf("redirect to %s", warning)
    +				}
    +				if len(via) >= 10 {
    +					return fmt.Errorf("too many redirects")
    +				}
    +				return nil
    +			},
     		},
    -		token: token,
    +		token:     token,
    +		validator: validator,
     	}
     }
     
    +// getUnauthenticated fetches a URL without the authorization header.
    +// Used for pre-signed URLs (like S3) that have their own auth mechanism.
    +func (s httpClient) getUnauthenticated(ctx context.Context, endpoint string) (*http.Response, error) {
    +	// validate URL to prevent SSRF attacks
    +	warning, err := s.validator.Validate(endpoint)
    +	if err != nil {
    +		return nil, fmt.Errorf("URL validation failed: %w", err)
    +	}
    +	if warning != "" {
    +		log.Warn(warning)
    +	}
    +
    +	request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
    +	if err != nil {
    +		return nil, err
    +	}
    +
    +	log.Tracef("http %s %s (unauthenticated)", request.Method, request.URL)
    +	//nolint:gosec // G704: URL is validated by validator.Validate above
    +	return s.client.Do(request)
    +}
    +
     func (s httpClient) get(ctx context.Context, endpoint string, body io.Reader) (*http.Response, error) {
     	request, err := http.NewRequest(http.MethodGet, endpoint, body)
     	if err != nil {
    @@ -48,7 +87,17 @@ func (s httpClient) post(ctx context.Context, endpoint string, body io.Reader) (
     }
     
     func (s httpClient) do(request *http.Request) (*http.Response, error) {
    +	// validate URL to prevent SSRF attacks
    +	warning, err := s.validator.Validate(request.URL.String())
    +	if err != nil {
    +		return nil, fmt.Errorf("URL validation failed: %w", err)
    +	}
    +	if warning != "" {
    +		log.Warn(warning)
    +	}
    +
     	log.Tracef("http %s %s", request.Method, request.URL)
     	request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.token))
    -	return s.client.Do(request) //nolint:gosec // G704 false positive: URLs are constructed internally for Apple's notary API
    +	//nolint:gosec // G704: URL is validated by validator.Validate above
    +	return s.client.Do(request)
     }
    
  • quill/notary/http_client_test.go+107 3 modified
    @@ -9,20 +9,34 @@ import (
     
     	"github.com/stretchr/testify/assert"
     	"github.com/stretchr/testify/require"
    +
    +	"github.com/anchore/quill/internal/urlvalidate"
     )
     
    +// testValidator creates a validator configured for test servers (allows http and 127.0.0.1).
    +func testValidator() *urlvalidate.Validator {
    +	cfg := urlvalidate.DefaultConfig()
    +	cfg.AllowedSchemes = append(cfg.AllowedSchemes, "http")
    +	cfg.TrustedDomains = append(cfg.TrustedDomains, "127.0.0.1")
    +	return urlvalidate.New(cfg)
    +}
    +
    +// newTestHTTPClient creates an httpClient configured for test servers (http + 127.0.0.1).
    +func newTestHTTPClient(token string, timeout time.Duration) *httpClient {
    +	return newHTTPClient(token, timeout, testValidator())
    +}
    +
     func Test_httpClient_get(t *testing.T) {
     	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     		assert.Equal(t, "GET", r.Method)
     		return
     	}))
     	defer s.Close()
    -	c := newHTTPClient("the-token", time.Second*3)
    +	c := newTestHTTPClient("the-token", time.Second*3)
     
     	resp, err := c.get(context.TODO(), s.URL, nil)
     	require.NoError(t, err)
     	require.Equal(t, "Bearer the-token", resp.Request.Header.Get("Authorization"))
    -
     }
     
     func Test_httpClient_post(t *testing.T) {
    @@ -31,10 +45,100 @@ func Test_httpClient_post(t *testing.T) {
     		return
     	}))
     	defer s.Close()
    -	c := newHTTPClient("the-token", time.Second*3)
    +	c := newTestHTTPClient("the-token", time.Second*3)
     
     	resp, err := c.post(context.TODO(), s.URL, nil)
     	require.NoError(t, err)
     	require.Equal(t, "application/json; charset=UTF-8", resp.Request.Header.Get("Content-Type"))
     	require.Equal(t, "Bearer the-token", resp.Request.Header.Get("Authorization"))
     }
    +
    +func Test_httpClient_getUnauthenticated(t *testing.T) {
    +	t.Run("follows redirect to allowed host", func(t *testing.T) {
    +		finalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			w.Write([]byte("final response"))
    +		}))
    +		defer finalServer.Close()
    +
    +		redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			http.Redirect(w, r, finalServer.URL, http.StatusFound)
    +		}))
    +		defer redirectServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		resp, err := c.getUnauthenticated(context.TODO(), redirectServer.URL)
    +		require.NoError(t, err)
    +		require.Equal(t, http.StatusOK, resp.StatusCode)
    +	})
    +
    +	t.Run("blocks redirect to denied host (localhost)", func(t *testing.T) {
    +		redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			// redirect to localhost which is on the denylist
    +			http.Redirect(w, r, "https://localhost/evil", http.StatusFound)
    +		}))
    +		defer redirectServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		_, err := c.getUnauthenticated(context.TODO(), redirectServer.URL)
    +		require.Error(t, err)
    +		require.Contains(t, err.Error(), "redirect to untrusted URL")
    +		require.Contains(t, err.Error(), "localhost")
    +	})
    +
    +	t.Run("blocks redirect to denied host (internal IP)", func(t *testing.T) {
    +		redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			// redirect to internal IP which is on the denylist
    +			http.Redirect(w, r, "https://192.168.1.1/internal", http.StatusFound)
    +		}))
    +		defer redirectServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		_, err := c.getUnauthenticated(context.TODO(), redirectServer.URL)
    +		require.Error(t, err)
    +		require.Contains(t, err.Error(), "redirect to untrusted URL")
    +	})
    +
    +	t.Run("blocks redirect to cloud metadata endpoint", func(t *testing.T) {
    +		redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			// redirect to AWS metadata endpoint
    +			http.Redirect(w, r, "https://169.254.169.254/latest/meta-data", http.StatusFound)
    +		}))
    +		defer redirectServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		_, err := c.getUnauthenticated(context.TODO(), redirectServer.URL)
    +		require.Error(t, err)
    +		require.Contains(t, err.Error(), "redirect to untrusted URL")
    +	})
    +
    +	t.Run("blocks too many redirects", func(t *testing.T) {
    +		var redirectCount int
    +		redirectServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			redirectCount++
    +			// keep redirecting to self
    +			http.Redirect(w, r, r.URL.String(), http.StatusFound)
    +		}))
    +		defer redirectServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		_, err := c.getUnauthenticated(context.TODO(), redirectServer.URL)
    +		require.Error(t, err)
    +		require.Contains(t, err.Error(), "too many redirects")
    +		// should stop at 10 redirects
    +		require.LessOrEqual(t, redirectCount, 11)
    +	})
    +
    +	t.Run("no auth header on redirected requests", func(t *testing.T) {
    +		finalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    +			// verify no auth header is sent (this is a presigned URL scenario)
    +			assert.Empty(t, r.Header.Get("Authorization"))
    +			w.Write([]byte("ok"))
    +		}))
    +		defer finalServer.Close()
    +
    +		c := newTestHTTPClient("the-token", time.Second*3)
    +		resp, err := c.getUnauthenticated(context.TODO(), finalServer.URL)
    +		require.NoError(t, err)
    +		require.Equal(t, http.StatusOK, resp.StatusCode)
    +	})
    +}
    
  • quill/pki/apple/internal/generate/main.go+38 5 modified
    @@ -10,8 +10,11 @@ import (
     	"net/url"
     	"os"
     	"path"
    +	"time"
     
     	"github.com/PuerkitoBio/goquery"
    +
    +	"github.com/anchore/quill/internal/urlvalidate"
     )
     
     const (
    @@ -24,6 +27,27 @@ type Link struct {
     	URL  string
     }
     
    +// urlValidator validates URLs for fetching Apple resources.
    +var urlValidator = urlvalidate.New(urlvalidate.DefaultConfig())
    +
    +// httpClient is a shared HTTP client with redirect validation to prevent SSRF attacks.
    +var httpClient = &http.Client{
    +	Timeout: 30 * time.Second,
    +	CheckRedirect: func(req *http.Request, via []*http.Request) error {
    +		warning, err := urlValidator.Validate(req.URL.String())
    +		if err != nil {
    +			return fmt.Errorf("redirect to untrusted URL: %w", err)
    +		}
    +		if warning != "" {
    +			log.Printf("warning: redirect URL: %s", warning)
    +		}
    +		if len(via) >= 10 {
    +			return fmt.Errorf("too many redirects")
    +		}
    +		return nil
    +	},
    +}
    +
     type AppleCALinks struct {
     	Roots         []Link
     	Intermediates []Link
    @@ -85,12 +109,21 @@ func mkdirs(dir string) error {
     	return nil
     }
     
    -func downloadCertTo(url, dest string) error {
    +func downloadCertTo(certURL, dest string) error {
    +	// validate URL before downloading to prevent SSRF attacks
    +	warning, err := urlValidator.Validate(certURL)
    +	if err != nil {
    +		return fmt.Errorf("invalid certificate URL: %w", err)
    +	}
    +	if warning != "" {
    +		log.Printf("warning: %s", warning)
    +	}
    +
     	if err := mkdirs(dest); err != nil {
     		return err
     	}
     
    -	by, err := download(url)
    +	by, err := download(certURL)
     	if err != nil {
     		return err
     	}
    @@ -118,7 +151,7 @@ func downloadCertTo(url, dest string) error {
     		return fmt.Errorf("unknown certificate format")
     	}
     
    -	basename := path.Base(url)
    +	basename := path.Base(certURL)
     	basename = basename[:len(basename)-len(path.Ext(basename))] + suffix
     	filepath := path.Join(dest, basename)
     
    @@ -146,8 +179,8 @@ func convertDERToPEM(der []byte) []byte {
     	return pem.EncodeToMemory(block)
     }
     
    -func download(url string) ([]byte, error) {
    -	resp, err := http.Get(url) //nolint:gosec // G107 is a false positive since the URL is a constant
    +func download(u string) ([]byte, error) {
    +	resp, err := httpClient.Get(u)
     	if err != nil {
     		return nil, err
     	}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.