Skip to content

Commit b74c913

Browse files
Fix three security issues: revocation bypass, serial truncation, CSR rate limit
- MEDIUM: auth middleware now calls IsRevokedSerial(cert.SerialNumber) instead of IsRevoked(CN), closing a bypass window where an old revoked credential could still authenticate after re-issuance for the same CN. Fail-closed: CRL read errors are treated as denials (was fail-open). New IsRevokedSerial method added to ca.Revoke; delegates to the existing private isRevokedSerial (used by OCSP) with a proper read lock. - LOW: CertStatusResponse.SerialNumber changed from *int64 to *string using big.Int.Text(10), preventing silent truncation of 128-bit random serials. - INFO: per-IP fixed-window rate limiter (ratelimit.go) added to the unauthenticated PUT /certificate_request endpoint. Configured via Server.CSRRateLimit (0 = disabled, default). Does not trust proxy headers. Tests updated across auth_test.go, ca_test.go, and api_test.go to detect regressions for all three issues, including the re-issuance bypass scenario and CRL-unavailable fail-closed behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c289170 commit b74c913

8 files changed

Lines changed: 399 additions & 49 deletions

File tree

internal/api/api_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,4 +884,81 @@ var _ = Describe("API Workflow", func() {
884884
})
885885
})
886886

887+
Context("per-IP rate limiting on CSR submission", func() {
888+
It("should return 429 when the per-IP limit is exceeded within the window", func() {
889+
// Build a server with a tight limit of 2 requests/minute.
890+
limitedServer := api.New(myCA)
891+
limitedServer.CSRRateLimit = 2
892+
limitedMux := limitedServer.Routes()
893+
894+
// First two requests succeed (200 or 409 — both mean the limiter allowed them through).
895+
for i := 0; i < 2; i++ {
896+
csrPEM, err := testutil.GenerateCSR("rl-node")
897+
Expect(err).NotTo(HaveOccurred())
898+
req := httptest.NewRequest("PUT", "/certificate_request/rl-node", bytes.NewReader(csrPEM))
899+
rr := httptest.NewRecorder()
900+
limitedMux.ServeHTTP(rr, req)
901+
Expect(rr.Code).NotTo(Equal(http.StatusTooManyRequests))
902+
}
903+
904+
// Third request from the same IP must be rate-limited.
905+
csrPEM, err := testutil.GenerateCSR("rl-node")
906+
Expect(err).NotTo(HaveOccurred())
907+
req := httptest.NewRequest("PUT", "/certificate_request/rl-node", bytes.NewReader(csrPEM))
908+
rr := httptest.NewRecorder()
909+
limitedMux.ServeHTTP(rr, req)
910+
Expect(rr.Code).To(Equal(http.StatusTooManyRequests))
911+
})
912+
913+
It("should not rate-limit when CSRRateLimit is zero (default)", func() {
914+
// The shared server has no rate limit set; submit many requests.
915+
for i := 0; i < 5; i++ {
916+
csrPEM, err := testutil.GenerateCSR("nolimit-node")
917+
Expect(err).NotTo(HaveOccurred())
918+
req := httptest.NewRequest("PUT", "/certificate_request/nolimit-node", bytes.NewReader(csrPEM))
919+
rr := httptest.NewRecorder()
920+
mux.ServeHTTP(rr, req)
921+
Expect(rr.Code).NotTo(Equal(http.StatusTooManyRequests))
922+
}
923+
})
924+
})
925+
926+
Context("serial_number in status response is a full decimal string", func() {
927+
It("should return serial_number as a non-empty decimal string without truncation", func() {
928+
subject := "serial-node"
929+
csrPEM, err := testutil.GenerateCSR(subject)
930+
Expect(err).NotTo(HaveOccurred())
931+
932+
// Submit CSR and sign it.
933+
mux.ServeHTTP(httptest.NewRecorder(),
934+
httptest.NewRequest("PUT", "/certificate_request/"+subject, bytes.NewReader(csrPEM)))
935+
body, _ := json.Marshal(api.PutStatusBody{DesiredState: "signed"})
936+
mux.ServeHTTP(httptest.NewRecorder(),
937+
httptest.NewRequest("PUT", "/certificate_status/"+subject, bytes.NewReader(body)))
938+
939+
// Fetch the signed cert and parse its serial for comparison.
940+
certRR := httptest.NewRecorder()
941+
mux.ServeHTTP(certRR, httptest.NewRequest("GET", "/certificate/"+subject, nil))
942+
Expect(certRR.Code).To(Equal(http.StatusOK))
943+
block, _ := pem.Decode(certRR.Body.Bytes())
944+
Expect(block).NotTo(BeNil())
945+
cert, err := x509.ParseCertificate(block.Bytes)
946+
Expect(err).NotTo(HaveOccurred())
947+
expectedSerial := cert.SerialNumber.Text(10)
948+
949+
// Fetch status and confirm serial_number matches exactly.
950+
statusRR := httptest.NewRecorder()
951+
mux.ServeHTTP(statusRR, httptest.NewRequest("GET", "/certificate_status/"+subject, nil))
952+
Expect(statusRR.Code).To(Equal(http.StatusOK))
953+
954+
var resp api.CertStatusResponse
955+
Expect(json.Unmarshal(statusRR.Body.Bytes(), &resp)).To(Succeed())
956+
Expect(resp.SerialNumber).NotTo(BeNil())
957+
// Must be a pure decimal string.
958+
Expect(*resp.SerialNumber).To(MatchRegexp(`^[0-9]+$`))
959+
// Must be the full, un-truncated value.
960+
Expect(*resp.SerialNumber).To(Equal(expectedSerial))
961+
})
962+
})
963+
887964
})

internal/api/auth.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,18 @@ func newAuthMiddleware(cfg *AuthConfig, myCA *ca.CA, next http.Handler) http.Han
9898

9999
clientCN := clientCert.Subject.CommonName
100100

101-
// Check whether the client cert has been revoked.
102-
if myCA.IsRevoked(clientCN) {
103-
slog.Debug("Auth: client cert is revoked", "cn", clientCN)
101+
// Check whether the presented cert's serial appears in the CRL.
102+
// We check the serial of the actual presented certificate — not the
103+
// serial of whatever cert happens to be on disk for the same CN —
104+
// so that old revoked credentials are rejected even after a
105+
// re-issuance for the same CN. Fail-closed: a CRL read error is
106+
// also treated as a denial.
107+
if revoked, err := myCA.IsRevokedSerial(clientCert.SerialNumber); err != nil || revoked {
108+
if err != nil {
109+
slog.Warn("Auth: CRL check failed (denying)", "cn", clientCN, "error", err)
110+
} else {
111+
slog.Debug("Auth: client cert is revoked", "cn", clientCN)
112+
}
104113
http.Error(w, "access denied", http.StatusForbidden)
105114
return
106115
}

internal/api/auth_test.go

Lines changed: 111 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,34 @@ var _ = Describe("Auth Middleware", func() {
265265
// ── Revoked client cert ────────────────────────────────────────────────────
266266

267267
Context("revoked client cert", func() {
268-
It("returns 403 even though the cert is CA-signed", func() {
269-
// Register the CN in the CA so Revoke can find it in inventory.
268+
It("returns 403 when the presented cert's serial is in the CRL", func() {
269+
// Sign a cert through the CA so its serial is tracked.
270+
csrPEM, err := testutil.GenerateCSR("revoked-client")
271+
Expect(err).NotTo(HaveOccurred())
272+
_, err = myCA.SaveRequest("revoked-client", csrPEM)
273+
Expect(err).NotTo(HaveOccurred())
274+
certPEM, err := myCA.Sign("revoked-client")
275+
Expect(err).NotTo(HaveOccurred())
276+
277+
// Parse the issued cert so we can present it in the TLS request.
278+
block, _ := pem.Decode(certPEM)
279+
issuedCert, err := x509.ParseCertificate(block.Bytes)
280+
Expect(err).NotTo(HaveOccurred())
281+
282+
// Revoke the cert — its serial is now in the CRL.
283+
Expect(myCA.Revoke("revoked-client")).To(Succeed())
284+
285+
// Present the revoked cert; the middleware checks its serial
286+
// directly against the CRL and must deny access.
287+
req := httptest.NewRequest("GET", "/certificate_request/revoked-client", nil)
288+
req = withClientCert(req, issuedCert)
289+
rr := httptest.NewRecorder()
290+
mux.ServeHTTP(rr, req)
291+
Expect(rr.Code).To(Equal(http.StatusForbidden))
292+
})
293+
294+
It("allows a cert whose serial is NOT in the CRL even when another cert for the same CN was revoked", func() {
295+
// Sign and revoke "revoked-client".
270296
csrPEM, err := testutil.GenerateCSR("revoked-client")
271297
Expect(err).NotTo(HaveOccurred())
272298
_, err = myCA.SaveRequest("revoked-client", csrPEM)
@@ -275,14 +301,90 @@ var _ = Describe("Auth Middleware", func() {
275301
Expect(err).NotTo(HaveOccurred())
276302
Expect(myCA.Revoke("revoked-client")).To(Succeed())
277303

278-
// Issue a fresh TLS cert with the revoked CN.
279-
// IsRevoked looks up the on-disk cert for the CN, reads its serial
280-
// number, and checks whether that serial is in the CRL. The TLS-
281-
// presented cert's serial is not consulted; only the CN is used to
282-
// locate the revoked record on disk.
283-
clientCert := issueClientCert("revoked-client", caCert, caKey)
304+
// A separately-issued cert with the same CN but a different serial
305+
// (not in the CRL) must pass the revocation check.
306+
freshCert := issueClientCert("revoked-client", caCert, caKey)
284307
req := httptest.NewRequest("GET", "/certificate_request/revoked-client", nil)
285-
req = withClientCert(req, clientCert)
308+
req = withClientCert(req, freshCert)
309+
rr := httptest.NewRecorder()
310+
mux.ServeHTTP(rr, req)
311+
// The cert is not revoked — access is denied only if it also
312+
// fails the tier check (self-or-admin: CN matches path subject).
313+
Expect(rr.Code).NotTo(Equal(http.StatusForbidden))
314+
})
315+
})
316+
317+
// ── Revocation bypass prevention (re-issuance regression) ─────────────────
318+
// Before the fix, IsRevoked looked up the cert *on disk* for the CN and
319+
// checked that cert's serial. After a revocation + re-issuance the disk
320+
// cert had a new (clean) serial, so the old revoked cert would pass.
321+
// IsRevokedSerial checks the serial of the PRESENTED cert, closing the gap.
322+
323+
Context("revocation bypass prevention after re-issuance", func() {
324+
It("denies an old revoked cert even after the same CN has been re-issued", func() {
325+
// Step 1: issue the first cert for "puppet-server" (admin CN).
326+
csrPEM1, err := testutil.GenerateCSR("puppet-server")
327+
Expect(err).NotTo(HaveOccurred())
328+
_, err = myCA.SaveRequest("puppet-server", csrPEM1)
329+
Expect(err).NotTo(HaveOccurred())
330+
certPEM1, err := myCA.Sign("puppet-server")
331+
Expect(err).NotTo(HaveOccurred())
332+
block1, _ := pem.Decode(certPEM1)
333+
oldCert, err := x509.ParseCertificate(block1.Bytes)
334+
Expect(err).NotTo(HaveOccurred())
335+
336+
// Step 2: revoke it — serial1 is now in the CRL.
337+
Expect(myCA.Revoke("puppet-server")).To(Succeed())
338+
339+
// Step 3: re-register and sign a new cert for the same CN.
340+
csrPEM2, err := testutil.GenerateCSR("puppet-server")
341+
Expect(err).NotTo(HaveOccurred())
342+
_, err = myCA.SaveRequest("puppet-server", csrPEM2) // evicts the revoked cert
343+
Expect(err).NotTo(HaveOccurred())
344+
certPEM2, err := myCA.Sign("puppet-server")
345+
Expect(err).NotTo(HaveOccurred())
346+
block2, _ := pem.Decode(certPEM2)
347+
newCert, err := x509.ParseCertificate(block2.Bytes)
348+
Expect(err).NotTo(HaveOccurred())
349+
350+
// OLD cert (revoked serial) must be denied — regression test.
351+
req := httptest.NewRequest("POST", "/sign/all", nil)
352+
req = withClientCert(req, oldCert)
353+
rr := httptest.NewRecorder()
354+
mux.ServeHTTP(rr, req)
355+
Expect(rr.Code).To(Equal(http.StatusForbidden))
356+
357+
// NEW cert (clean serial, same admin CN) must be allowed.
358+
req2 := httptest.NewRequest("POST", "/sign/all", nil)
359+
req2 = withClientCert(req2, newCert)
360+
rr2 := httptest.NewRecorder()
361+
mux.ServeHTTP(rr2, req2)
362+
Expect(rr2.Code).NotTo(Equal(http.StatusForbidden))
363+
})
364+
})
365+
366+
// ── CRL unavailable → fail-closed ─────────────────────────────────────────
367+
368+
Context("CRL unavailable", func() {
369+
It("returns 403 when the CRL file cannot be read (fail-closed)", func() {
370+
// Sign a cert through the CA so it is a valid client cert.
371+
csrPEM, err := testutil.GenerateCSR("crl-test-node")
372+
Expect(err).NotTo(HaveOccurred())
373+
_, err = myCA.SaveRequest("crl-test-node", csrPEM)
374+
Expect(err).NotTo(HaveOccurred())
375+
certPEM, err := myCA.Sign("crl-test-node")
376+
Expect(err).NotTo(HaveOccurred())
377+
block, _ := pem.Decode(certPEM)
378+
issuedCert, err := x509.ParseCertificate(block.Bytes)
379+
Expect(err).NotTo(HaveOccurred())
380+
381+
// Remove the CRL file to simulate a disk fault.
382+
Expect(os.Remove(store.CRLPath())).To(Succeed())
383+
384+
// The middleware must deny the request (fail-closed) rather than
385+
// allowing access because it cannot check revocation status.
386+
req := httptest.NewRequest("GET", "/certificate_request/crl-test-node", nil)
387+
req = withClientCert(req, issuedCert)
286388
rr := httptest.NewRecorder()
287389
mux.ServeHTTP(rr, req)
288390
Expect(rr.Code).To(Equal(http.StatusForbidden))

internal/api/handlers.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ type AuthConfig struct {
4646
type Server struct {
4747
CA *ca.CA
4848
AuthConfig *AuthConfig
49+
// CSRRateLimit is the maximum number of CSR submissions allowed per IP
50+
// address per minute on the unauthenticated PUT /certificate_request
51+
// endpoint. Zero (the default) disables rate limiting.
52+
CSRRateLimit int
53+
54+
csrLimiter *ipRateLimiter
4955
}
5056

5157
func New(c *ca.CA) *Server {
@@ -56,6 +62,10 @@ func New(c *ca.CA) *Server {
5662
// Puppet agents use the /puppet-ca/v1/ prefix; we support both bare and prefixed paths
5763
// so the Go CA can be used directly or behind a stripping proxy.
5864
func (s *Server) Routes() http.Handler {
65+
if s.CSRRateLimit > 0 {
66+
s.csrLimiter = newIPRateLimiter(s.CSRRateLimit, time.Minute)
67+
}
68+
5969
mux := http.NewServeMux()
6070

6171
routes := []struct {
@@ -109,7 +119,9 @@ type CertStatusResponse struct {
109119
// Always present, empty map when none exist.
110120
AuthorizationExtensions map[string]string `json:"authorization_extensions"`
111121
// Populated when signed or revoked.
112-
SerialNumber *int64 `json:"serial_number,omitempty"`
122+
// SerialNumber is a decimal string to preserve the full 128-bit value
123+
// without loss; int64 would silently truncate random CA/B-Forum serials.
124+
SerialNumber *string `json:"serial_number,omitempty"`
113125
NotBefore *string `json:"not_before,omitempty"`
114126
NotAfter *string `json:"not_after,omitempty"`
115127
}
@@ -272,6 +284,11 @@ func (s *Server) handleGetRequest(w http.ResponseWriter, r *http.Request) {
272284
}
273285

274286
func (s *Server) handlePutRequest(w http.ResponseWriter, r *http.Request) {
287+
if s.csrLimiter != nil && !s.csrLimiter.Allow(clientIP(r)) {
288+
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
289+
return
290+
}
291+
275292
subject := r.PathValue("subject")
276293
if err := ca.ValidateSubject(subject); err != nil {
277294
http.Error(w, "invalid subject", http.StatusBadRequest)
@@ -428,7 +445,7 @@ func noNilSlice(s []string) []string {
428445
func certStatusFromCert(subject string, certPEM []byte, state string) CertStatusResponse {
429446
cert := parseCert(certPEM)
430447
fp := fingerprint(certPEM)
431-
serial := cert.SerialNumber.Int64()
448+
serial := cert.SerialNumber.Text(10) // decimal string; preserves full 128-bit value
432449
nb := cert.NotBefore.UTC().Format(time.RFC3339)
433450
na := cert.NotAfter.UTC().Format(time.RFC3339)
434451
dnsNames := noNilSlice(cert.DNSNames)

internal/api/ratelimit.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (C) 2026 Trevor Vaughan
2+
//
3+
// This program is free software; you can redistribute it and/or modify
4+
// it under the terms of the GNU General Public License as published by
5+
// the Free Software Foundation; either version 2 of the License, or
6+
// (at your option) any later version.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU General Public License along
14+
// with this program; if not, write to the Free Software Foundation, Inc.,
15+
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16+
17+
package api
18+
19+
import (
20+
"net"
21+
"net/http"
22+
"sync"
23+
"time"
24+
)
25+
26+
// ipRateLimiter is a fixed-window per-IP rate limiter.
27+
// Each IP address is allowed at most maxReqs requests per window duration.
28+
// Old windows are evicted lazily on access so memory stays bounded over time.
29+
type ipRateLimiter struct {
30+
mu sync.Mutex
31+
window time.Duration
32+
maxReqs int
33+
entries map[string]*rlEntry
34+
}
35+
36+
type rlEntry struct {
37+
start time.Time
38+
count int
39+
}
40+
41+
func newIPRateLimiter(maxReqs int, window time.Duration) *ipRateLimiter {
42+
return &ipRateLimiter{
43+
window: window,
44+
maxReqs: maxReqs,
45+
entries: make(map[string]*rlEntry),
46+
}
47+
}
48+
49+
// Allow reports whether the request from ip should be allowed.
50+
// Returns false when the per-window request count has been exceeded.
51+
func (l *ipRateLimiter) Allow(ip string) bool {
52+
now := time.Now()
53+
l.mu.Lock()
54+
defer l.mu.Unlock()
55+
56+
e, ok := l.entries[ip]
57+
if !ok || now.Sub(e.start) >= l.window {
58+
l.entries[ip] = &rlEntry{start: now, count: 1}
59+
return true
60+
}
61+
if e.count >= l.maxReqs {
62+
return false
63+
}
64+
e.count++
65+
return true
66+
}
67+
68+
// clientIP extracts the remote IP address from r, stripping the port.
69+
// It does not trust X-Forwarded-For or similar headers since the server
70+
// accepts direct connections (no trusted reverse proxy layer).
71+
func clientIP(r *http.Request) string {
72+
host, _, err := net.SplitHostPort(r.RemoteAddr)
73+
if err != nil {
74+
return r.RemoteAddr
75+
}
76+
return host
77+
}

0 commit comments

Comments
 (0)