Skip to content

Stop reproducing file-upload field values in config decode errors - #1113

Merged
Bencheng21 merged 2 commits into
mainfrom
ben.su/CE-1284/stop-file-upload-value-leak
Aug 28, 2026
Merged

Stop reproducing file-upload field values in config decode errors#1113
Bencheng21 merged 2 commits into
mainfrom
ben.su/CE-1284/stop-file-upload-value-leak

Conversation

@Bencheng21

Copy link
Copy Markdown
Contributor

Summary

FileUploadDecodeHook's path-resolution branch (readFromPath=true, used by the CLI, gRPC server, and capabilities commands) wraps three stdlib errors directly:

  • os.Stat failure -> cannot access file: %w
  • os.ReadFile failure -> error reading file: %w
  • url.Parse failure (in the data: URL branch, reachable from either mode) -> invalid data URL: %w

*fs.PathError and *url.Error both embed the raw input string in their Error() output. Since this field type carries credential files (service-account JSON, certs, private keys), a value supplied in the wrong shape — e.g. the file's raw content passed where a path was expected — gets written verbatim to stderr and whatever collects it. WithIsSecret(true) does not protect this: it only feeds GUI schema rendering (schemaFieldToV1 in pkg/field/marshal.go) and is never visible to the decode hook, whose mapstructure.DecodeHookFunc signature only receives types and the raw value.

Fix

  • Added redactPathError, which unwraps an *fs.PathError to its .Err field via errors.As, dropping .Path. Applied at both os.Stat/os.ReadFile sites in getFileContentFromPath.
  • Applied the same unwrap pattern to the url.Parse failure in parseJSONBase64DataURL, dropping the quoted URL and keeping only the underlying parse-failure reason.

The failure category (not found / permission denied / name too long / invalid control character) is preserved for diagnosability; the field value itself is not.

Test plan

  • go build ./...
  • go vet ./pkg/field/...
  • golangci-lint run ./pkg/field/... (0 issues)
  • Existing pkg/field test suite passes unmodified (no behavior change on valid input)
  • Added TestFileUploadDecodeHook_DoesNotLeakValueInErrors, covering all three sites, asserting a secret marker never appears in the resulting error string
  • Full repo go test ./... passes

Resolves CE-1284.

…e errors

FileUploadDecodeHook's path-resolution branch wrapped os.Stat, os.ReadFile,
and url.Parse errors directly, all three of which embed the raw input in
their Error() string. Since this field type carries credential files
(service-account JSON, certs, private keys), a value supplied in the wrong
shape wrote the value itself to stderr/logs. WithIsSecret does not protect
this path: it only feeds GUI schema rendering and is never visible to the
decode hook.

Unwrap each error down to its underlying OS/parse reason (e.g. "no such
file or directory") before wrapping, discarding the embedded path/URL/value.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@linear-code

linear-code Bot commented Aug 28, 2026

Copy link
Copy Markdown

CE-1284

Comment thread pkg/field/decode_hooks.go
// Unwrap to the underlying parse failure so it isn't echoed back.
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.

Comment thread pkg/field/decode_hooks.go
Comment on lines +103 to +109
func redactPathError(err error) error {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return pathErr.Err
}
return 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, 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.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Stop reproducing file-upload field values in config decode errors

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 70559f5bb7f4.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. The change adds redactPathError (unwraps *fs.PathError to drop .Path) at both os.Stat/os.ReadFile sites in getFileContentFromPath, applies the same unwrap to *url.Error in parseJSONBase64DataURL, and adds a regression test asserting a secret marker never appears in the resulting error. The redaction is effective for the three sites it targets: the surviving syscall.Errno and net/url: invalid control character in URL errors are static strings, errors.Is/os.IsNotExist still match through syscall.Errno, nothing else in the repo asserts on these error strings, and mapstructure/viper wrap decode-hook errors with the field name only. One narrow strictness gap is noted below as a suggestion.

Risk triage (per docs/BUG_CATCHING.md §2): Silence — no, the change only rewrites text on an already-failing path. Durability — no, nothing serialized; no proto, wire, c1z, or pagination-state surface. Uncontrolled dimensions — no concurrency, scheduling, version-pair, or cost-curve sensitivity. Consumer distance — same-process error text, plus downstream connectors that may match on the error chain. Consequence — rung 1 (redeploy). Verdict: LOW, so no escalation to the full pass-set review; the three added sub-tests cover each redacted site.

The two prior findings (mediaType echoed at lines 152/155, and *fs.PathError dropped from the errors.As chain) are both still accurate against this head and are intentionally not re-flagged here.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/field/decode_hooks.go:133 — forwarding urlErr.Err still lets a url.EscapeError through, whose message embeds three characters of the input (invalid URL escape "%XY"); reachable only when the value starts with data: and contains # followed by a bad escape. (low confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/field/decode_hooks.go`:
- Around line 128-136: parseJSONBase64DataURL unwraps *url.Error to urlErr.Err to avoid
  echoing the raw URL, but urlErr.Err can itself be a url.EscapeError, whose Error() renders
  as: invalid URL escape "%XY" -- and therefore reproduces three bytes of the field value.
  This happens when url.Parse fails inside fragment handling, i.e. the value starts with
  "data:" and contains a "#" followed by an invalid percent-escape. To make the "field value
  is never reproduced" invariant hold unconditionally, type-switch on the unwrapped error:
  for *url.EscapeError (and url.InvalidHostError, which likewise embeds input) return a
  static message such as "invalid data URL: malformed percent-escape" instead of wrapping
  the original error, and keep forwarding other urlErr.Err values as today. Optionally extend
  TestFileUploadDecodeHook_DoesNotLeakValueInErrors with a case using a value like
  "data:application/json;base64,SECRETPAYLOAD#%ZZ" asserting the marker does not appear.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

…unwrap

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/field/decode_hooks.go
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: 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)

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

@Bencheng21
Bencheng21 merged commit ac6a46f into main Aug 28, 2026
12 checks passed
@Bencheng21
Bencheng21 deleted the ben.su/CE-1284/stop-file-upload-value-leak branch August 28, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants