Skip to content

Commit ca4f4e3

Browse files
authored
Update certificate code to be more strict (#1056)
Deal with malformed PEM that is skipped by the golang standard library Signed-off-by: Todd Short <[email protected]>
1 parent 957fc1b commit ca4f4e3

File tree

7 files changed

+249
-68
lines changed

7 files changed

+249
-68
lines changed

internal/httputil/certutil.go

+219
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package httputil
2+
3+
import (
4+
"bytes"
5+
"crypto/x509"
6+
"encoding/base64"
7+
"encoding/pem"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
)
12+
13+
var pemStart = []byte("\n-----BEGIN ")
14+
var pemEnd = []byte("\n-----END ")
15+
var pemEndOfLine = []byte("-----")
16+
var colon = []byte(":")
17+
18+
// getLine results the first \r\n or \n delineated line from the given byte
19+
// array. The line does not include trailing whitespace or the trailing new
20+
// line bytes. The remainder of the byte array (also not including the new line
21+
// bytes) is also returned and this will always be smaller than the original
22+
// argument.
23+
func getLine(data []byte) ([]byte, []byte) {
24+
i := bytes.IndexByte(data, '\n')
25+
var j int
26+
if i < 0 {
27+
i = len(data)
28+
j = i
29+
} else {
30+
j = i + 1
31+
if i > 0 && data[i-1] == '\r' {
32+
i--
33+
}
34+
}
35+
return bytes.TrimRight(data[0:i], " \t"), data[j:]
36+
}
37+
38+
// removeSpacesAndTabs returns a copy of its input with all spaces and tabs
39+
// removed, if there were any. Otherwise, the input is returned unchanged.
40+
//
41+
// The base64 decoder already skips newline characters, so we don't need to
42+
// filter them out here.
43+
func removeSpacesAndTabs(data []byte) []byte {
44+
if !bytes.ContainsAny(data, " \t") {
45+
// Fast path; most base64 data within PEM contains newlines, but
46+
// no spaces nor tabs. Skip the extra alloc and work.
47+
return data
48+
}
49+
result := make([]byte, len(data))
50+
n := 0
51+
52+
for _, b := range data {
53+
if b == ' ' || b == '\t' {
54+
continue
55+
}
56+
result[n] = b
57+
n++
58+
}
59+
60+
return result[0:n]
61+
}
62+
63+
// This version of pem.Decode() is a bit less flexible, it will not skip over bad PEM
64+
// It is basically the guts of pem.Decode() inside the outer for loop, with error
65+
// returns rather than continues
66+
func pemDecode(data []byte) (*pem.Block, []byte) {
67+
// pemStart begins with a newline. However, at the very beginning of
68+
// the byte array, we'll accept the start string without it.
69+
rest := data
70+
if bytes.HasPrefix(rest, pemStart[1:]) {
71+
rest = rest[len(pemStart)-1:]
72+
} else if _, after, ok := bytes.Cut(rest, pemStart); ok {
73+
rest = after
74+
} else {
75+
return nil, data
76+
}
77+
78+
var typeLine []byte
79+
typeLine, rest = getLine(rest)
80+
if !bytes.HasSuffix(typeLine, pemEndOfLine) {
81+
return nil, data
82+
}
83+
typeLine = typeLine[0 : len(typeLine)-len(pemEndOfLine)]
84+
85+
p := &pem.Block{
86+
Headers: make(map[string]string),
87+
Type: string(typeLine),
88+
}
89+
90+
for {
91+
// This loop terminates because getLine's second result is
92+
// always smaller than its argument.
93+
if len(rest) == 0 {
94+
return nil, data
95+
}
96+
line, next := getLine(rest)
97+
98+
key, val, ok := bytes.Cut(line, colon)
99+
if !ok {
100+
break
101+
}
102+
103+
key = bytes.TrimSpace(key)
104+
val = bytes.TrimSpace(val)
105+
p.Headers[string(key)] = string(val)
106+
rest = next
107+
}
108+
109+
var endIndex, endTrailerIndex int
110+
111+
// If there were no headers, the END line might occur
112+
// immediately, without a leading newline.
113+
if len(p.Headers) == 0 && bytes.HasPrefix(rest, pemEnd[1:]) {
114+
endIndex = 0
115+
endTrailerIndex = len(pemEnd) - 1
116+
} else {
117+
endIndex = bytes.Index(rest, pemEnd)
118+
endTrailerIndex = endIndex + len(pemEnd)
119+
}
120+
121+
if endIndex < 0 {
122+
return nil, data
123+
}
124+
125+
// After the "-----" of the ending line, there should be the same type
126+
// and then a final five dashes.
127+
endTrailer := rest[endTrailerIndex:]
128+
endTrailerLen := len(typeLine) + len(pemEndOfLine)
129+
if len(endTrailer) < endTrailerLen {
130+
return nil, data
131+
}
132+
133+
restOfEndLine := endTrailer[endTrailerLen:]
134+
endTrailer = endTrailer[:endTrailerLen]
135+
if !bytes.HasPrefix(endTrailer, typeLine) ||
136+
!bytes.HasSuffix(endTrailer, pemEndOfLine) {
137+
return nil, data
138+
}
139+
140+
// The line must end with only whitespace.
141+
if s, _ := getLine(restOfEndLine); len(s) != 0 {
142+
return nil, data
143+
}
144+
145+
base64Data := removeSpacesAndTabs(rest[:endIndex])
146+
p.Bytes = make([]byte, base64.StdEncoding.DecodedLen(len(base64Data)))
147+
n, err := base64.StdEncoding.Decode(p.Bytes, base64Data)
148+
if err != nil {
149+
return nil, data
150+
}
151+
p.Bytes = p.Bytes[:n]
152+
153+
// the -1 is because we might have only matched pemEnd without the
154+
// leading newline if the PEM block was empty.
155+
_, rest = getLine(rest[endIndex+len(pemEnd)-1:])
156+
return p, rest
157+
}
158+
159+
// This version of (*x509.CertPool).AppendCertsFromPEM() will error out if parsing fails
160+
func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte) error {
161+
n := 1
162+
for len(pemCerts) > 0 {
163+
var block *pem.Block
164+
block, pemCerts = pemDecode(pemCerts)
165+
if block == nil {
166+
return fmt.Errorf("unable to PEM decode cert %d", n)
167+
}
168+
// ignore non-certificates (e.g. keys)
169+
if block.Type != "CERTIFICATE" {
170+
continue
171+
}
172+
if len(block.Headers) != 0 {
173+
// This is a cert, but we're ignoring it, so bump the counter
174+
n++
175+
continue
176+
}
177+
178+
cert, err := x509.ParseCertificate(block.Bytes)
179+
if err != nil {
180+
return fmt.Errorf("unable to parse cert %d: %w", n, err)
181+
}
182+
// no return values - panics or always succeeds
183+
s.AddCert(cert)
184+
n++
185+
}
186+
187+
return nil
188+
}
189+
190+
func NewCertPool(caDir string) (*x509.CertPool, error) {
191+
caCertPool, err := x509.SystemCertPool()
192+
if err != nil {
193+
return nil, err
194+
}
195+
if caDir == "" {
196+
return caCertPool, nil
197+
}
198+
199+
dirEntries, err := os.ReadDir(caDir)
200+
if err != nil {
201+
return nil, err
202+
}
203+
for _, e := range dirEntries {
204+
if e.IsDir() {
205+
continue
206+
}
207+
file := filepath.Join(caDir, e.Name())
208+
data, err := os.ReadFile(file)
209+
if err != nil {
210+
return nil, fmt.Errorf("error reading cert file %q: %w", file, err)
211+
}
212+
err = appendCertsFromPEM(caCertPool, data)
213+
if err != nil {
214+
return nil, fmt.Errorf("error adding cert file %q: %w", file, err)
215+
}
216+
}
217+
218+
return caCertPool, nil
219+
}

internal/httputil/httputil_test.go renamed to internal/httputil/certutil_test.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ func TestNewCertPool(t *testing.T) {
2222
msg string
2323
}{
2424
{"../../testdata/certs/good", ""},
25-
{"../../testdata/certs/bad", "unable to PEM decode cert 1"},
26-
{"../../testdata/certs/ugly", ""},
25+
{"../../testdata/certs/bad", `error adding cert file "../../testdata/certs/bad/Amazon_Root_CA_2.pem": unable to PEM decode cert 1`},
26+
{"../../testdata/certs/ugly", `error adding cert file "../../testdata/certs/ugly/Amazon_Root_CA.pem": unable to PEM decode cert 2`},
27+
{"../../testdata/certs/ugly2", `error adding cert file "../../testdata/certs/ugly2/Amazon_Root_CA_1.pem": unable to PEM decode cert 1`},
28+
{"../../testdata/certs/ugly3", `error adding cert file "../../testdata/certs/ugly3/not_a_cert.pem": unable to PEM decode cert 1`},
29+
{"../../testdata/certs/empty", `error adding cert file "../../testdata/certs/empty/empty.pem": unable to parse cert 1: x509: malformed certificate`},
2730
}
2831

2932
for _, caDir := range caDirs {
33+
t.Logf("Loading certs from %q", caDir.dir)
3034
pool, err := httputil.NewCertPool(caDir.dir)
3135
if caDir.msg == "" {
3236
require.NoError(t, err)

internal/httputil/httputil.go

-66
Original file line numberDiff line numberDiff line change
@@ -3,76 +3,10 @@ package httputil
33
import (
44
"crypto/tls"
55
"crypto/x509"
6-
"encoding/pem"
7-
"fmt"
86
"net/http"
9-
"os"
10-
"path/filepath"
117
"time"
128
)
139

14-
// This version of (*x509.CertPool).AppendCertsFromPEM() will error out if parsing fails
15-
func appendCertsFromPEM(s *x509.CertPool, pemCerts []byte) error {
16-
n := 1
17-
for len(pemCerts) > 0 {
18-
var block *pem.Block
19-
block, pemCerts = pem.Decode(pemCerts)
20-
if block == nil {
21-
return fmt.Errorf("unable to PEM decode cert %d", n)
22-
}
23-
// ignore non-certificates (e.g. keys)
24-
if block.Type != "CERTIFICATE" {
25-
continue
26-
}
27-
if len(block.Headers) != 0 {
28-
// This is a cert, but we're ignoring it, so bump the counter
29-
n++
30-
continue
31-
}
32-
33-
cert, err := x509.ParseCertificate(block.Bytes)
34-
if err != nil {
35-
return fmt.Errorf("unable to parse cert %d: %w", n, err)
36-
}
37-
// no return values - panics or always succeeds
38-
s.AddCert(cert)
39-
n++
40-
}
41-
42-
return nil
43-
}
44-
45-
func NewCertPool(caDir string) (*x509.CertPool, error) {
46-
caCertPool, err := x509.SystemCertPool()
47-
if err != nil {
48-
return nil, err
49-
}
50-
if caDir == "" {
51-
return caCertPool, nil
52-
}
53-
54-
dirEntries, err := os.ReadDir(caDir)
55-
if err != nil {
56-
return nil, err
57-
}
58-
for _, e := range dirEntries {
59-
if e.IsDir() {
60-
continue
61-
}
62-
file := filepath.Join(caDir, e.Name())
63-
data, err := os.ReadFile(file)
64-
if err != nil {
65-
return nil, fmt.Errorf("error reading cert file %q: %w", file, err)
66-
}
67-
err = appendCertsFromPEM(caCertPool, data)
68-
if err != nil {
69-
return nil, fmt.Errorf("error adding cert file %q: %w", file, err)
70-
}
71-
}
72-
73-
return caCertPool, nil
74-
}
75-
7610
func BuildHTTPClient(caCertPool *x509.CertPool) (*http.Client, error) {
7711
httpClient := &http.Client{Timeout: 10 * time.Second}
7812

testdata/certs/empty/empty.pem

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-----BEGIN CERTIFICATE-----
2+
-----END CERTIFICATE-----

testdata/certs/good/Amazon_Root_CA_2.pem

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
-----BEGIN CERTIFICATE-----
2+
Header: value
23
MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF
34
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
45
b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE
2+
MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF
3+
ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6
4+
b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL
5+
MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv
6+
b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj
7+
ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM
8+
9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw
9+
IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6
10+
VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L
11+
93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm
12+
jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC
13+
AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA
14+
A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI
15+
U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs
16+
N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv
17+
o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU
18+
5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy
19+
rqXRfboQnoZsG4q5WTP468SQvvG5
20+
-----END CERTIFICATE-----

testdata/certs/ugly3/not_a_cert.pem

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello World!

0 commit comments

Comments
 (0)