Skip to content
Merged
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
18 changes: 16 additions & 2 deletions pkg/field/decode_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package field

import (
"encoding/base64"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"reflect"
Expand Down Expand Up @@ -75,7 +77,7 @@ func getFileContentFromPath(path string) ([]byte, error) {
// Check if the file exists
fileInfo, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("cannot access file: %w", err)
return nil, fmt.Errorf("cannot access file: %w", redactPathError(err))
}

// Check file size limit (2MB)
Expand All @@ -87,11 +89,19 @@ func getFileContentFromPath(path string) ([]byte, error) {
// Read the file
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
return nil, fmt.Errorf("error reading file: %w", redactPathError(err))
}
return content, nil
}

func redactPathError(err error) error {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return pathErr.Err
}
return err
}
Comment on lines +97 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (low confidence, SDK compatibility): returning pathErr.Err drops *fs.PathError from the error chain, so a downstream caller doing errors.As(err, &pathErr) to inspect Op/Path no longer matches. Sentinel checks are unaffected (errors.Is(err, fs.ErrNotExist) / os.IsNotExist still work, since the syscall.Errno is preserved), and this error only surfaces through the config decode path, so real breakage is unlikely — worth a line in the PR description as an intentional error-shape change rather than a code change.


// parseFileContent returns the file upload content from a string field value.
func parseFileContent(data string) ([]byte, error) {
if data == "" {
Expand All @@ -118,6 +128,10 @@ func parseFileContent(data string) ([]byte, error) {
func parseJSONBase64DataURL(dataURL string) ([]byte, error) {
parsedURL, err := url.Parse(dataURL)
if err != nil {
var urlErr *url.Error
if errors.As(err, &urlErr) {
return nil, fmt.Errorf("invalid data URL: %w", urlErr.Err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (low confidence): this redacts the url.Parse failure, but two sibling error sites in the same function still format value-derived text — expected base64 data, got: %s (line 161) and expected MIME type application/json, got: %s (line 164) both print mediaType, which is the raw field value from after data: up to the first comma. Reachability is narrow (the value must literally start with data: and contain a comma before any real media type), so this is a consistency gap rather than the same exposure the PR fixes, but redacting or truncating mediaType would close the function completely.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Unwrapping to urlErr.Err removes the quoted URL, but one underlying error type still carries value bytes: if the failure comes from url.Parse's fragment handling, urlErr.Err is a url.EscapeError, whose Error() is invalid URL escape "%XY" — three characters of the input. Reachability is narrow (the value must start with data: and contain # followed by a bad escape), so this is a strictness gap against the "never reproduced" invariant the new test asserts rather than a practical leak. If you want the invariant to hold unconditionally, map *url.EscapeError (and url.InvalidHostError) to a static message instead of forwarding them. (low confidence)

}
return nil, fmt.Errorf("invalid data URL: %w", err)
}

Expand Down
47 changes: 47 additions & 0 deletions pkg/field/decode_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,53 @@ import (
"github.com/stretchr/testify/require"
)

// TestFileUploadDecodeHook_DoesNotLeakValueInErrors guards against
// regressions of CE-1284: the field's value (which may be secret file
// content, not an actual path) must never be reproduced in a decode error.
func TestFileUploadDecodeHook_DoesNotLeakValueInErrors(t *testing.T) {
tempDir := t.TempDir()

t.Run("stat failure on a secret-like value does not echo it", func(t *testing.T) {
secret := "TOTALLY-SECRET-VALUE-THAT-MUST-NOT-LEAK"

_, err := mapstructure.DecodeHookExec(
FileUploadDecodeHook(true),
reflect.ValueOf(secret),
reflect.ValueOf([]byte{}),
)
require.Error(t, err)
require.NotContains(t, err.Error(), secret)
})

t.Run("read failure on a directory does not echo its name", func(t *testing.T) {
secretDirName := "TOTALLY-SECRET-DIR-NAME"
secretDir := filepath.Join(tempDir, secretDirName)
require.NoError(t, os.Mkdir(secretDir, 0700))

_, err := mapstructure.DecodeHookExec(
FileUploadDecodeHook(true),
reflect.ValueOf(secretDir),
reflect.ValueOf([]byte{}),
)
require.Error(t, err)
require.NotContains(t, err.Error(), secretDirName)
})

t.Run("invalid data URL does not echo the payload", func(t *testing.T) {
secret := "SECRET\x00PAYLOAD-THAT-MUST-NOT-LEAK"
dataURL := "data:application/json;base64," + secret

_, err := mapstructure.DecodeHookExec(
FileUploadDecodeHook(false),
reflect.ValueOf(dataURL),
reflect.ValueOf([]byte{}),
)
require.Error(t, err)
require.NotContains(t, err.Error(), secret)
require.NotContains(t, err.Error(), "PAYLOAD-THAT-MUST-NOT-LEAK")
})
}

func TestFileUploadDecodeHook(t *testing.T) {
// Create a temporary file for testing
tempDir := t.TempDir()
Expand Down
Loading