Skip to content

Commit 8ee68c8

Browse files
authored
fix(certificate): guard against nil GetCertificateContext response in Update (#174)
* fix(certificate): guard against nil GetCertificateContext response in Update The Update path called GetCertificateContext and, when it returned (nil, err), only logged a warning via hasAPIErrors before dereferencing certGetResp.ContentBytes — causing a SIGSEGV nil-pointer dereference during terraform apply (observed when flipping certificate_format to PFX on an in-place update). The Read path was hardened with certGetResp != nil guards in v2.9.0, but Update was missed. Update now returns a clean diagnostic error and bails out when the certificate GET fails or returns a nil response, before any dereference. All subsequent certGetResp accesses in Update were already nil-guarded. Adds TestUnitKeyfactorCertificateResource_UpdateNilGetResponse, an httptest-backed regression test that drives Update with a failing certificate GET and asserts a diagnostic error is surfaced instead of a panic. * test(certificate): tighten Update nil-deref regression test + update-scoped diagnostic Add ERR_SUMMARY_CERTIFICATE_RESOURCE_UPDATE and use it in the defensive certGetResp == nil guard in Update, so the update path no longer borrows the read-path diagnostic summary. Correct the regression test's docstring/comments to describe what is actually exercised (GET 500 -> hasAPIErrors early return, not the format-change/parseLeafCert path) and note the certGetResp == nil guard is belt-and-suspenders for a (nil, nil) response not reproducible via the httptest harness. Trim the plan to only the fields load-bearing before the early return.
1 parent 6c3c929 commit 8ee68c8

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

keyfactor/constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
ERR_SUMMARY_INVALID_CERTIFICATE_RESOURCE = "Invalid certificate resource definition."
1818
ERR_SUMMARY_CERTIFICATE_RESOURCE_CREATE = "Unable to create Keyfactor Command certificate."
1919
ERR_SUMMARY_CERTIFICATE_RESOURCE_READ = "Unable to read Keyfactor Command certificate."
20+
ERR_SUMMARY_CERTIFICATE_RESOURCE_UPDATE = "Unable to update Keyfactor Command certificate."
2021
ERR_SUMMARY_CERT_STORE_READ = "Unable to read Keyfactor Command certificate store."
2122
ERR_SUMMARY_AGENT_READ = "Unable to read Keyfactor Command agent."
2223
ERR_SUMMARY_TEMPLATE_READ = "Unable to read Keyfactor Command template."

keyfactor/resource_keyfactor_certificate.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1484,7 +1484,26 @@ func (r resourceCommandCertificate) Update(
14841484
// Fetch certificate context
14851485
certGetResp, apiErr := r.p.client.GetCertificateContext(apiArgs)
14861486
if hasAPIErrors(ctx, apiErr, state.ID.Value, &response.Diagnostics) {
1487-
tflog.Warn(ctx, fmt.Sprintf("Failed to retrieve certificate from GET /Certificates/%d", certificateID))
1487+
// GetCertificateContext returns (nil, err) on failure; hasAPIErrors has
1488+
// already appended an error diagnostic. We MUST return here, otherwise
1489+
// the certGetResp.ContentBytes dereference below panics with a nil
1490+
// pointer (SIGSEGV). The Read path was hardened with certGetResp != nil
1491+
// guards in v2.9.0; this aligns Update with that behavior.
1492+
tflog.Error(ctx, fmt.Sprintf("Failed to retrieve certificate from GET /Certificates/%d", certificateID))
1493+
return
1494+
}
1495+
if certGetResp == nil {
1496+
// Defensive: GET returned no error but a nil response. Returning here
1497+
// prevents a nil-pointer dereference on certGetResp.ContentBytes below.
1498+
tflog.Error(ctx, fmt.Sprintf("GET /Certificates/%d returned a nil response", certificateID))
1499+
response.Diagnostics.AddError(
1500+
ERR_SUMMARY_CERTIFICATE_RESOURCE_UPDATE,
1501+
fmt.Sprintf(
1502+
"Could not retrieve certificate '%s' from Keyfactor Command during update: "+
1503+
"the API returned an empty response.", state.ID.Value,
1504+
),
1505+
)
1506+
return
14881507
}
14891508

14901509
leaf, lDiags := parseLeafCert(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package keyfactor
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/Keyfactor/keyfactor-auth-client-go/auth_providers"
10+
"github.com/Keyfactor/keyfactor-go-client/v3/api"
11+
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
12+
"github.com/hashicorp/terraform-plugin-framework/types"
13+
)
14+
15+
// certUpdateMockAuthConfig implements api.AuthConfig for httptest-backed unit
16+
// tests of the certificate resource Update path.
17+
type certUpdateMockAuthConfig struct {
18+
server *httptest.Server
19+
}
20+
21+
func (m *certUpdateMockAuthConfig) GetServerConfig() *auth_providers.Server {
22+
return &auth_providers.Server{
23+
Host: m.server.URL,
24+
APIPath: "KeyfactorAPI",
25+
SkipTLSVerify: true,
26+
}
27+
}
28+
29+
func (m *certUpdateMockAuthConfig) GetHttpClient() (*http.Client, error) {
30+
return m.server.Client(), nil
31+
}
32+
33+
func (m *certUpdateMockAuthConfig) Authenticate() error { return nil }
34+
func (m *certUpdateMockAuthConfig) GetCommandVersion() string { return "25.1.0.0" }
35+
36+
func newCertUpdateMockClient(server *httptest.Server) *api.Client {
37+
return &api.Client{
38+
AuthClient: &certUpdateMockAuthConfig{server: server},
39+
}
40+
}
41+
42+
// TestUnitKeyfactorCertificateResource_UpdateNilGetResponse is a regression test
43+
// for the SIGSEGV nil-pointer dereference in resourceCommandCertificate.Update
44+
// that crashed `terraform apply` during an in-place update.
45+
//
46+
// Root cause: GetCertificateContext returns (nil, err) on failure, but the code
47+
// only logged a warning via hasAPIErrors and then dereferenced
48+
// certGetResp.ContentBytes (via parseLeafCert), panicking. The Read path was
49+
// hardened with `certGetResp != nil` guards in v2.9.0; the fix aligns Update by
50+
// returning at the hasAPIErrors branch.
51+
//
52+
// What this test exercises: the GET fails (HTTP 500), so GetCertificateContext
53+
// returns (nil, err) and hasAPIErrors early-returns a clean diagnostic instead
54+
// of the provider panicking at parseLeafCert(certGetResp.ContentBytes). On the
55+
// UNFIXED code this panics (the deferred recover converts the panic into a test
56+
// failure); on the fixed code Update returns with
57+
// response.Diagnostics.HasError() == true and the diagnostic summary emitted by
58+
// hasAPIErrors (ERR_SUMMARY_CERTIFICATE_RESOURCE_READ).
59+
//
60+
// Note: the second `certGetResp == nil` guard in Update is belt-and-suspenders
61+
// for a (nil, nil) GET response. That is NOT reproducible through this httptest
62+
// harness — every 200 yields a non-nil *GetCertificateResponse, and the only
63+
// nil-data branch returns a non-nil error — so that guard (and its
64+
// ERR_SUMMARY_CERTIFICATE_RESOURCE_UPDATE summary) is not directly exercised
65+
// here.
66+
func TestUnitKeyfactorCertificateResource_UpdateNilGetResponse(t *testing.T) {
67+
ctx := context.Background()
68+
69+
// httptest server: every request under /KeyfactorAPI/Certificates returns
70+
// HTTP 500 so GetCertificateContext returns (nil, err).
71+
mux := http.NewServeMux()
72+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
73+
w.WriteHeader(http.StatusInternalServerError)
74+
_, _ = w.Write([]byte(`{"Message":"simulated server error"}`))
75+
})
76+
server := httptest.NewTLSServer(mux)
77+
defer server.Close()
78+
79+
client := newCertUpdateMockClient(server)
80+
81+
// Build the resource schema so we can populate Plan and State.
82+
schema, sDiags := resourceCommandCertificateType{}.GetSchema(ctx)
83+
if sDiags.HasError() {
84+
t.Fatalf("GetSchema returned diagnostics: %+v", sDiags)
85+
}
86+
87+
// Only the fields load-bearing before the hasAPIErrors early return are
88+
// populated: a CN (no CSR) so the CSR-vs-CN validation passes, the
89+
// ID/CertificateId used to build the GET args, CollectionId, and
90+
// ExpiryWarningDays. The null list/map element types are required for
91+
// Plan.Set / State.Set to succeed. Fields consumed only after the GET (key
92+
// password, SAN lists, metadata, owner role, revoke-on-destroy, certificate
93+
// format) are intentionally omitted — Update returns at hasAPIErrors before
94+
// they are read.
95+
nullList := types.List{ElemType: types.StringType, Null: true}
96+
nullMap := types.Map{ElemType: types.StringType, Null: true}
97+
98+
plan := CommandCertificate{
99+
ID: types.String{Value: "845070"},
100+
CertificateId: types.Int64{Value: 845070},
101+
CommonName: types.String{Value: "tf-unit-nil-update.example.com"},
102+
CSR: types.String{Null: true},
103+
CollectionId: types.Int64{Value: 0},
104+
ExpiryWarningDays: types.Int64{Null: true},
105+
DNSSANs: nullList,
106+
IPSANs: nullList,
107+
URISANs: nullList,
108+
Metadata: nullMap,
109+
}
110+
111+
state := plan
112+
113+
planObj := tfsdk.Plan{Schema: schema}
114+
if d := planObj.Set(ctx, &plan); d.HasError() {
115+
t.Fatalf("test setup: plan.Set returned diagnostics: %+v", d)
116+
}
117+
stateObj := tfsdk.State{Schema: schema}
118+
if d := stateObj.Set(ctx, &state); d.HasError() {
119+
t.Fatalf("test setup: state.Set returned diagnostics: %+v", d)
120+
}
121+
122+
r := resourceCommandCertificate{p: provider{configured: true, client: client}}
123+
124+
req := tfsdk.UpdateResourceRequest{Plan: planObj, State: stateObj}
125+
resp := &tfsdk.UpdateResourceResponse{State: tfsdk.State{Schema: schema}}
126+
127+
// The whole point of this regression test: Update must NOT panic when
128+
// GetCertificateContext returns (nil, err). Convert any panic into a clear
129+
// test failure so the unfixed code is observably red.
130+
func() {
131+
defer func() {
132+
if rec := recover(); rec != nil {
133+
t.Fatalf("Update panicked (nil-deref regression): %v", rec)
134+
}
135+
}()
136+
r.Update(ctx, req, resp)
137+
}()
138+
139+
if !resp.Diagnostics.HasError() {
140+
t.Fatalf("expected Update to surface a diagnostic error when the certificate GET fails, got none")
141+
}
142+
143+
// Sanity: the diagnostic should mention the certificate read failure, not a
144+
// generic panic message.
145+
var found bool
146+
for _, d := range resp.Diagnostics.Errors() {
147+
if d.Summary() == ERR_SUMMARY_CERTIFICATE_RESOURCE_READ {
148+
found = true
149+
break
150+
}
151+
}
152+
if !found {
153+
t.Logf("diagnostics: %+v", resp.Diagnostics)
154+
t.Fatalf("expected a %q error diagnostic", ERR_SUMMARY_CERTIFICATE_RESOURCE_READ)
155+
}
156+
}

0 commit comments

Comments
 (0)