Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion syntheticsclientv2/synthetics.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ type ClientArgs struct {
}

type RequestDetails struct {
StatusCode int
StatusCode int
// ResponseBody is the raw, unredacted API response body and must be used
// for parsing responses into typed structs.
ResponseBody string
// SanitizedResponseBody is the supported debug representation of the
// response body. It redacts sensitive JSON values before being returned
// and must be used instead of ResponseBody for logging or persisting
// debug details.
SanitizedResponseBody string
// RequestBody is the supported debug representation of the outgoing request.
// It redacts API tokens and sensitive JSON values before being returned.
RequestBody string
Expand Down Expand Up @@ -119,6 +126,7 @@ func (c Client) makePublicAPICall(method string, endpoint string, requestBody io
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
var errRes errorResponse
if err = json.NewDecoder(resp.Body).Decode(&errRes); err == nil {
redactErrorResponseFields(&errRes)
errorField, err2 := json.Marshal(errRes)
if err2 != nil {
return &details, fmt.Errorf("unknown issue while parsing API error response, status code: %d", resp.StatusCode)
Expand All @@ -134,6 +142,7 @@ func (c Client) makePublicAPICall(method string, endpoint string, requestBody io
}

details.ResponseBody = string(responseBody)
details.SanitizedResponseBody = sanitizeResponseBody(details.ResponseBody)

return &details, nil
}
Expand Down Expand Up @@ -177,6 +186,41 @@ func redactRequestLineURLQuery(requestLine string) string {
return strings.Join(requestLineParts, " ") + lineSuffix
}

// sanitizeResponseBody returns a redacted copy of a raw JSON API response
// body, suitable for debug logging. It leaves the original response body
// returned by makePublicAPICall untouched so that parse*Response call sites
// continue to unmarshal real values.
func sanitizeResponseBody(responseBody string) string {
if strings.TrimSpace(responseBody) == "" {
return responseBody
}

var response interface{}
if err := json.Unmarshal([]byte(responseBody), &response); err != nil {
return "[REDACTED]"
}

redactSensitiveJSONFields(response)

redactedResponseBody, err := json.Marshal(response)
if err != nil {
return "[REDACTED]"
}

return string(redactedResponseBody)
}

// redactErrorResponseFields redacts sensitive JSON fields and headers echoed
// back in an API error response, including its free-form Details map,
// before the error response is marshaled into the returned error string.
func redactErrorResponseFields(errRes *errorResponse) {
if errRes == nil || len(errRes.Details) == 0 {
return
}

redactSensitiveJSONFields(errRes.Details)
}

func redactSensitiveRequestBody(requestDump string) string {
headers, body, separator, ok := splitRequestDump(requestDump)
if !ok || strings.TrimSpace(body) == "" {
Expand Down
108 changes: 108 additions & 0 deletions syntheticsclientv2/synthetics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package syntheticsclientv2

import (
"bytes"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -413,3 +414,110 @@ func TestMakePublicAPICallDoesNotExposeRawRequest(t *testing.T) {
t.Fatalf("expected sanitized RequestBody to include redacted content field, but saw: %s", details.RequestBody)
}
}

func TestMakePublicAPICallRedactsSensitiveFieldsInSanitizedResponseBody(t *testing.T) {
testMux = http.NewServeMux()
testServer = httptest.NewServer(testMux)
defer testServer.Close()

totpSecret := "totp-secret-material"
certContent := "private-certificate-material"
certPassword := "private-key-password"

testMux.HandleFunc("/totps/1", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"totp":{"id":1,"name":"login-totp","secret":"` + totpSecret + `","digits":6,"privateKey":{"content":"` + certContent + `","password":"` + certPassword + `"}}}`))
})

testConfigurableClient := NewConfigurableClient("apiKey", "realm", ClientArgs{
publicBaseUrl: testServer.URL,
})

details, err := testConfigurableClient.makePublicAPICall("GET", "/totps/1", bytes.NewBufferString("{}"), nil)
if err != nil {
t.Fatalf("expected no error, but saw: %s", err.Error())
}

for _, secret := range []string{totpSecret, certContent, certPassword} {
if strings.Contains(details.SanitizedResponseBody, secret) {
t.Fatalf("sanitized response body leaked %q: %s", secret, details.SanitizedResponseBody)
}
}
for _, redacted := range []string{`"secret":"[REDACTED]"`, `"content":"[REDACTED]"`, `"password":"[REDACTED]"`} {
if !strings.Contains(details.SanitizedResponseBody, redacted) {
t.Fatalf("sanitized response body missing %q: %s", redacted, details.SanitizedResponseBody)
}
}
if !strings.Contains(details.SanitizedResponseBody, `"digits":6`) {
t.Fatalf("sanitized response body should preserve unrelated fields, but saw: %s", details.SanitizedResponseBody)
}
if !strings.Contains(details.SanitizedResponseBody, `"name":"login-totp"`) {
t.Fatalf("sanitized response body should preserve unrelated fields, but saw: %s", details.SanitizedResponseBody)
}

if strings.Contains(details.ResponseBody, "[REDACTED]") {
t.Fatalf("expected raw ResponseBody used for parsing to remain unredacted, but saw: %s", details.ResponseBody)
}
if !strings.Contains(details.ResponseBody, totpSecret) {
t.Fatalf("expected raw ResponseBody to retain real secret for parse*Response call sites, but saw: %s", details.ResponseBody)
}

var parsed TotpVariableV2Response
if err := json.Unmarshal([]byte(details.ResponseBody), &parsed); err != nil {
t.Fatalf("expected raw ResponseBody to remain valid JSON for parsing, but saw error: %s", err.Error())
}
if parsed.Totp.Secret != totpSecret {
t.Fatalf("expected parsed response to retain real secret, but saw: %s", parsed.Totp.Secret)
}
}

func TestMakePublicAPICallSanitizedResponseBodyHandlesEmptyAndMalformedBodies(t *testing.T) {
if got := sanitizeResponseBody(""); got != "" {
t.Fatalf("expected empty response body to remain unchanged, but saw: %q", got)
}
if got := sanitizeResponseBody(" "); got != " " {
t.Fatalf("expected blank response body to remain unchanged, but saw: %q", got)
}

malformed := `{"totp":{"secret":"unterminated`
if got := sanitizeResponseBody(malformed); got != "[REDACTED]" {
t.Fatalf("expected malformed response body to be fully redacted, but saw: %q", got)
}
}

func TestMakePublicAPICallRedactsErrorResponseDetails(t *testing.T) {
testMux = http.NewServeMux()
testServer = httptest.NewServer(testMux)
defer testServer.Close()

echoedPassword := "echoed-password-secret"
echoedContent := "echoed-content-secret"

testMux.HandleFunc("/tests", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"status":"400","message":"validation failed","details":{"password":"` + echoedPassword + `","content":"` + echoedContent + `","field":"name"}}`))
})

testConfigurableClient := NewConfigurableClient("apiKey", "realm", ClientArgs{
publicBaseUrl: testServer.URL,
})

_, err := testConfigurableClient.makePublicAPICall("POST", "/tests", bytes.NewBufferString(`{}`), nil)
if err == nil {
t.Fatal("expected an error for a 400 status code")
}

for _, secret := range []string{echoedPassword, echoedContent} {
if strings.Contains(err.Error(), secret) {
t.Fatalf("error response leaked %q: %s", secret, err.Error())
}
}
for _, redacted := range []string{`"password":"[REDACTED]"`, `"content":"[REDACTED]"`} {
if !strings.Contains(err.Error(), redacted) {
t.Fatalf("error response missing %q: %s", redacted, err.Error())
}
}
if !strings.Contains(err.Error(), `"field":"name"`) {
t.Fatalf("error response should preserve unrelated fields, but saw: %s", err.Error())
}
}
Loading