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

DoS in Quill via unbounded read of HTTP response body during notarization

CVE-2026-31960

Description

Quill provides simple mac binary signing and notarization from any platform. Quill before version v0.7.1 has unbounded reads of HTTP response bodies during the Apple notarization process. 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 processing HTTP responses during notarization, Quill reads the entire response body into memory without any size limit. An attacker who can control or modify the response content can return an arbitrarily large payload, causing the Quill client to run out of memory and crash. The impact is limited to availability; there is no effect on confidentiality or integrity. Both the Quill CLI and library are affected when used to perform notarization operations. 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 unbounded read of HTTP response bodies during Apple notarization, enabling a denial-of-service crash via large payloads.

Vulnerability

Overview

CVE-2026-31960 describes an unbounded read vulnerability in Quill, a tool for macOS binary signing and notarization. The flaw exists in how Quill processes HTTP responses from Apple's notarization service: the entire response body is read into memory without any size limit [1]. This occurs in the handleResponse function used by the API client for operations such as submission, status checks, and log retrieval [3].

Exploitation

Context

Exploitation requires the ability to trigger a denial-of-service condition requires the ability to modify API responses from Apple's notarization service. Under standard network conditions, this is not feasible 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 [1]. An attacker who can control or modify the response content can return an arbitrarily large payload, causing the Quill client to run out of memory and crash [1].

Impact

The impact is limited to availability; there is no effect on confidentiality or integrity [1]. Both the Quill CLI and library are affected when used to perform notarization operations [1]. The vulnerability is fixed in version 0.7.1, which introduces maximum response size limits: 5 MB for API JSON responses and 50 MB for log files [3][4].

Mitigation

Users should upgrade to Quill v0.7.1 or later [4]. For environments where upgrading is not immediately possible, avoiding the use of TLS-intercepting proxies or ensuring proper certificate validation can reduce the attack surface, though the vendor-recommended mitigation is to apply the patch [1][3].

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
9cdb0823ea1d

do not allow for unbounded reads for user controlled input (#681)

https://github.com/anchore/quillAlex GoodmanMar 10, 2026via ghsa
8 files changed · +303 16
  • .golangci.yaml+4 0 modified
    @@ -50,6 +50,10 @@ linters:
           - linters:
               - revive
             text: "var-naming: avoid package names that conflict"
    +      # utils is a commonly used helper package name
    +      - linters:
    +          - revive
    +        text: "var-naming: avoid meaningless package names"
         paths:
           - third_party$
           - builtin$
    
  • internal/utils/indent.go+1 1 modified
    @@ -1,4 +1,4 @@
    -package utils //nolint:revive // existing package name
    +package utils
     
     import "strings"
     
    
  • internal/utils/io.go+19 0 added
    @@ -0,0 +1,19 @@
    +package utils
    +
    +import (
    +	"fmt"
    +	"io"
    +)
    +
    +// ReadAllLimited reads up to maxBytes from r. Returns error if limit exceeded.
    +func ReadAllLimited(r io.Reader, maxBytes int64) ([]byte, error) {
    +	limitedReader := io.LimitReader(r, maxBytes+1)
    +	data, err := io.ReadAll(limitedReader)
    +	if err != nil {
    +		return nil, err
    +	}
    +	if int64(len(data)) > maxBytes {
    +		return nil, fmt.Errorf("response size exceeds limit of %d bytes", maxBytes)
    +	}
    +	return data, nil
    +}
    
  • internal/utils/io_test.go+91 0 added
    @@ -0,0 +1,91 @@
    +package utils
    +
    +import (
    +	"bytes"
    +	"strings"
    +	"testing"
    +
    +	"github.com/stretchr/testify/require"
    +)
    +
    +func TestReadAllLimited(t *testing.T) {
    +	tests := []struct {
    +		name     string
    +		input    string
    +		maxBytes int64
    +		want     string
    +		wantErr  require.ErrorAssertionFunc
    +	}{
    +		{
    +			name:     "reads data under limit",
    +			input:    "hello world",
    +			maxBytes: 100,
    +			want:     "hello world",
    +		},
    +		{
    +			name:     "reads data exactly at limit",
    +			input:    "hello",
    +			maxBytes: 5,
    +			want:     "hello",
    +		},
    +		{
    +			name:     "returns error when data exceeds limit",
    +			input:    "hello world",
    +			maxBytes: 5,
    +			wantErr:  require.Error,
    +		},
    +		{
    +			name:     "handles empty input",
    +			input:    "",
    +			maxBytes: 100,
    +			want:     "",
    +		},
    +		{
    +			name:     "handles zero limit with empty input",
    +			input:    "",
    +			maxBytes: 0,
    +			want:     "",
    +		},
    +		{
    +			name:     "returns error for zero limit with data",
    +			input:    "a",
    +			maxBytes: 0,
    +			wantErr:  require.Error,
    +		},
    +		{
    +			name:     "handles large data at boundary minus one",
    +			input:    strings.Repeat("x", 999),
    +			maxBytes: 1000,
    +			want:     strings.Repeat("x", 999),
    +		},
    +		{
    +			name:     "handles large data at exact boundary",
    +			input:    strings.Repeat("x", 1000),
    +			maxBytes: 1000,
    +			want:     strings.Repeat("x", 1000),
    +		},
    +		{
    +			name:     "returns error for large data over boundary",
    +			input:    strings.Repeat("x", 1001),
    +			maxBytes: 1000,
    +			wantErr:  require.Error,
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			if tt.wantErr == nil {
    +				tt.wantErr = require.NoError
    +			}
    +
    +			reader := bytes.NewReader([]byte(tt.input))
    +			got, err := ReadAllLimited(reader, tt.maxBytes)
    +			tt.wantErr(t, err)
    +
    +			if err != nil {
    +				return
    +			}
    +			require.Equal(t, tt.want, string(got))
    +		})
    +	}
    +}
    
  • quill/notary/api_client.go+28 10 modified
    @@ -5,7 +5,6 @@ import (
     	"context"
     	"encoding/json"
     	"fmt"
    -	"io"
     	"net/http"
     	"net/url"
     	"path"
    @@ -22,6 +21,12 @@ import (
     	"github.com/anchore/quill/internal/log"
     	"github.com/anchore/quill/internal/redact"
     	"github.com/anchore/quill/internal/urlvalidate"
    +	"github.com/anchore/quill/internal/utils"
    +)
    +
    +const (
    +	maxAPIResponseSize = 5 * 1024 * 1024  // 5 MB for API JSON responses
    +	maxLogResponseSize = 50 * 1024 * 1024 // 50 MB for log files
     )
     
     type api interface {
    @@ -63,7 +68,7 @@ func (s APIClient) submissionRequest(ctx context.Context, request submissionRequ
     		return nil, err
     	}
     
    -	response, err := s.http.post(ctx, s.api, bytes.NewReader(requestBytes))
    +	response, err := s.http.post(ctx, s.api, bytes.NewReader(requestBytes)) //nolint:bodyclose // body is closed in handleResponse
     	body, err := s.handleResponse(response, err)
     	if err != nil {
     		return nil, err
    @@ -119,7 +124,7 @@ func (s APIClient) uploadBinary(ctx context.Context, response submissionResponse
     }
     
     func (s APIClient) submissionStatusRequest(ctx context.Context, id string) (*submissionStatusResponse, error) {
    -	response, err := s.http.get(ctx, joinURL(s.api, id), nil)
    +	response, err := s.http.get(ctx, joinURL(s.api, id), nil) //nolint:bodyclose // body is closed in handleResponse
     	body, err := s.handleResponse(response, err)
     	if err != nil {
     		return nil, err
    @@ -133,7 +138,7 @@ func (s APIClient) submissionStatusRequest(ctx context.Context, id string) (*sub
     }
     
     func (s APIClient) submissionList(ctx context.Context) (*submissionListResponse, error) {
    -	response, err := s.http.get(ctx, s.api, nil)
    +	response, err := s.http.get(ctx, s.api, nil) //nolint:bodyclose // body is closed in handleResponse
     	body, err := s.handleResponse(response, err)
     	if err != nil {
     		return nil, err
    @@ -147,7 +152,7 @@ func (s APIClient) submissionList(ctx context.Context) (*submissionListResponse,
     }
     
     func (s APIClient) submissionLogs(ctx context.Context, id string) (string, error) {
    -	metadataResp, err := s.http.get(ctx, joinURL(s.api, id, "logs"), nil)
    +	metadataResp, err := s.http.get(ctx, joinURL(s.api, id, "logs"), nil) //nolint:bodyclose // body is closed in handleResponse
     	body, err := s.handleResponse(metadataResp, err)
     	if err != nil {
     		return "", fmt.Errorf("unable to fetch log metadata with ID=%s: %w", id, err)
    @@ -160,9 +165,10 @@ func (s APIClient) submissionLogs(ctx context.Context, id string) (string, error
     
     	redactPresignedURLParams(resp.Data.Attributes.DeveloperLogURL)
     
    -	// fetch logs without auth header (it's a presigned URL with its own auth)
    +	// fetch logs without auth (presigned URL), with redirect validation for SSRF protection.
    +	// use a larger size limit since log files can be bigger than typical API responses.
     	logsResp, err := s.http.getUnauthenticated(ctx, resp.Data.Attributes.DeveloperLogURL)
    -	contents, err := s.handleResponse(logsResp, err)
    +	contents, err := s.handleResponseWithLimit(logsResp, err, maxLogResponseSize)
     	if err != nil {
     		return "", fmt.Errorf("unable to fetch log destination with ID=%s: %w", id, err)
     	}
    @@ -171,16 +177,28 @@ func (s APIClient) submissionLogs(ctx context.Context, id string) (string, error
     }
     
     func (s APIClient) handleResponse(response *http.Response, err error) ([]byte, error) {
    +	return s.handleResponseWithLimit(response, err, maxAPIResponseSize)
    +}
    +
    +func (s APIClient) handleResponseWithLimit(response *http.Response, err error, maxBytes int64) ([]byte, error) {
    +	// ensure body is always closed, even if there's an error
    +	if response != nil && response.Body != nil {
    +		defer response.Body.Close()
    +	}
    +
     	if err != nil {
     		return nil, err
     	}
     
    +	if response == nil {
    +		return nil, fmt.Errorf("nil response")
    +	}
    +
     	var body []byte
     
     	if response.Body != nil {
    -		defer response.Body.Close()
    -
    -		body, err = io.ReadAll(response.Body)
    +		// limit response size to prevent memory exhaustion from malicious responses
    +		body, err = utils.ReadAllLimited(response.Body, maxBytes)
     		if err != nil {
     			return nil, err
     		}
    
  • quill/notary/api_client_test.go+149 0 modified
    @@ -5,6 +5,7 @@ import (
     	"encoding/json"
     	"net/http"
     	"net/http/httptest"
    +	"strings"
     	"testing"
     	"time"
     
    @@ -195,3 +196,151 @@ func Test_apiClient_submissionLogs_rejectsDeniedURLs(t *testing.T) {
     		})
     	}
     }
    +
    +func Test_apiClient_handleResponse_enforcesMaxSize(t *testing.T) {
    +	tests := []struct {
    +		name        string
    +		size        int
    +		wantErr     require.ErrorAssertionFunc
    +		errContains string
    +	}{
    +		{
    +			name: "accepts response under limit",
    +			size: 1024, // 1 KB
    +		},
    +		{
    +			name: "accepts response at limit",
    +			size: maxAPIResponseSize,
    +		},
    +		{
    +			name:        "rejects response over limit",
    +			size:        maxAPIResponseSize + 1,
    +			wantErr:     require.Error,
    +			errContains: "exceeds limit",
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			if tt.wantErr == nil {
    +				tt.wantErr = require.NoError
    +			}
    +
    +			mux := http.NewServeMux()
    +			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    +				// write a response of the specified size
    +				data := strings.Repeat("x", tt.size)
    +				w.Write([]byte(data))
    +			})
    +
    +			s := httptest.NewServer(mux)
    +			defer s.Close()
    +
    +			resp, err := http.Get(s.URL)
    +			require.NoError(t, err)
    +
    +			c := APIClient{}
    +			_, err = c.handleResponse(resp, nil)
    +			tt.wantErr(t, err)
    +
    +			if tt.errContains != "" {
    +				require.Contains(t, err.Error(), tt.errContains)
    +			}
    +		})
    +	}
    +}
    +
    +func Test_apiClient_handleResponseWithLimit_enforcesCustomLimit(t *testing.T) {
    +	customLimit := int64(100)
    +
    +	tests := []struct {
    +		name        string
    +		size        int
    +		wantErr     require.ErrorAssertionFunc
    +		errContains string
    +	}{
    +		{
    +			name: "accepts response under custom limit",
    +			size: 50,
    +		},
    +		{
    +			name: "accepts response at custom limit",
    +			size: int(customLimit),
    +		},
    +		{
    +			name:        "rejects response over custom limit",
    +			size:        int(customLimit) + 1,
    +			wantErr:     require.Error,
    +			errContains: "exceeds limit",
    +		},
    +	}
    +
    +	for _, tt := range tests {
    +		t.Run(tt.name, func(t *testing.T) {
    +			if tt.wantErr == nil {
    +				tt.wantErr = require.NoError
    +			}
    +
    +			mux := http.NewServeMux()
    +			mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    +				data := strings.Repeat("x", tt.size)
    +				w.Write([]byte(data))
    +			})
    +
    +			s := httptest.NewServer(mux)
    +			defer s.Close()
    +
    +			resp, err := http.Get(s.URL)
    +			require.NoError(t, err)
    +
    +			c := APIClient{}
    +			_, err = c.handleResponseWithLimit(resp, nil, customLimit)
    +			tt.wantErr(t, err)
    +
    +			if tt.errContains != "" {
    +				require.Contains(t, err.Error(), tt.errContains)
    +			}
    +		})
    +	}
    +}
    +
    +func Test_apiClient_submissionLogs_usesLargerLimit(t *testing.T) {
    +	// create a log response larger than maxAPIResponseSize but smaller than maxLogResponseSize
    +	logSize := maxAPIResponseSize + 1024 // just over the API limit
    +	require.True(t, logSize < maxLogResponseSize, "test assumes logSize < maxLogResponseSize")
    +
    +	id := "the-id"
    +	expectedLogResponse := submissionLogsResponse{
    +		Data: submissionLogsResponseData{
    +			submissionResponseDescriptor: submissionResponseDescriptor{
    +				Type: "the-ty",
    +				ID:   id,
    +			},
    +		},
    +	}
    +
    +	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)
    +	})
    +
    +	mux.HandleFunc("/place-where-the-logs-are", func(w http.ResponseWriter, r *http.Request) {
    +		// write a large log response
    +		data := strings.Repeat("x", logSize)
    +		w.Write([]byte(data))
    +	})
    +
    +	s := httptest.NewServer(mux)
    +	expectedLogResponse.Data.Attributes.DeveloperLogURL = s.URL + "/place-where-the-logs-are"
    +	defer s.Close()
    +
    +	c := newTestAPIClient("the-token", time.Second*30)
    +	c.api = s.URL
    +
    +	// this should succeed because logs use maxLogResponseSize, not maxAPIResponseSize
    +	actual, err := c.submissionLogs(context.Background(), id)
    +	require.NoError(t, err)
    +	require.Len(t, actual, logSize)
    +}
    
  • quill/pki/apple/internal/generate/main.go+6 3 modified
    @@ -15,11 +15,13 @@ import (
     	"github.com/PuerkitoBio/goquery"
     
     	"github.com/anchore/quill/internal/urlvalidate"
    +	"github.com/anchore/quill/internal/utils"
     )
     
     const (
    -	CertsDir   = "certs"
    -	AppleCaURL = "https://www.apple.com/certificateauthority/"
    +	CertsDir           = "certs"
    +	AppleCaURL         = "https://www.apple.com/certificateauthority/"
    +	maxCertificateSize = 1 * 1024 * 1024 // 1 MB for certificates
     )
     
     type Link struct {
    @@ -185,7 +187,8 @@ func download(u string) ([]byte, error) {
     		return nil, err
     	}
     	defer resp.Body.Close()
    -	return io.ReadAll(resp.Body)
    +	// limit response size to prevent memory exhaustion from unexpectedly large responses
    +	return utils.ReadAllLimited(resp.Body, maxCertificateSize)
     }
     
     func findCALinks(html []byte, url string) (*AppleCALinks, error) {
    
  • quill/pki/load/bytes.go+5 2 modified
    @@ -3,13 +3,15 @@ package load
     import (
     	"encoding/base64"
     	"fmt"
    -	"io"
     	"os"
     	"strings"
     
     	"github.com/anchore/quill/internal/log"
    +	"github.com/anchore/quill/internal/utils"
     )
     
    +const maxPKIFileSize = 10 * 1024 * 1024 // 10 MB for PKI files
    +
     func BytesFromFileOrEnv(path string) ([]byte, error) {
     	if strings.HasPrefix(path, "env:") {
     		// comes from an env var...
    @@ -57,5 +59,6 @@ func BytesFromFileOrEnv(path string) ([]byte, error) {
     
     	defer f.Close()
     
    -	return io.ReadAll(f)
    +	// limit file size to prevent memory exhaustion from unexpectedly large files
    +	return utils.ReadAllLimited(f, maxPKIFileSize)
     }
    

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.