Skip to content

fix(cors): validate ACCESS_CONTROL_MAX_AGE and ALLOW_CREDENTIALS at startup (#3941) - #4052

Open
alanjollyc wants to merge 8 commits into
gofr-dev:developmentfrom
alanjollyc:fix/validate-cors-config-values
Open

alanjollyc wants to merge 8 commits into
gofr-dev:developmentfrom
alanjollyc:fix/validate-cors-config-values

Conversation

@alanjollyc

Copy link
Copy Markdown

Fixes #3941

Raised against the "note for the implementer" in that issue; I've also asked for it to be assigned on the thread. Happy to close this if you'd rather it went to someone else.

What

middleware.GetConfigs copied every ACCESS_CONTROL_* value into the CORS header map on a non-empty check alone, and setMiddlewareHeaders wrote it to the response verbatim. A value like 10m, 600s, -1 or abc therefore produced a malformed Access-Control-Max-Age header — browsers discard it and fall back to their own preflight cache, so the caching the user configured silently never happened and nothing in the logs said why. ACCESS_CONTROL_ALLOW_CREDENTIALS had the same gap for non-boolean values.

Both keys are now validated as they are read:

  • ACCESS_CONTROL_MAX_AGE must parse as a non-negative number of seconds
  • ACCESS_CONTROL_ALLOW_CREDENTIALS must parse as a boolean

An invalid value is dropped and reported with a warning naming the config key and the offending value, instead of being emitted in a form the browser ignores. Keys without a defined value syntax are passed through unchanged.

Before / after

ACCESS_CONTROL_ALLOW_ORIGIN=* ACCESS_CONTROL_MAX_AGE=10m go run ./main.go
curl -i -X OPTIONS localhost:8000/some-route

Before — invalid header, nothing in the logs:

Access-Control-Max-Age: 10m

After — header omitted, and at startup:

WARN invalid value "10m" for config ACCESS_CONTROL_MAX_AGE, expected a non-negative number of seconds: dropping the header

On the logger

Per the "note for the implementer" in the issue: GetConfigs had no logger, so it now takes the lean logging surface it actually needs (Warnf) — the same injection Logging(probes, logger) already uses in this package — and factory.go passes app.container.Logger. GetConfigs is internal plumbing with no references in docs/ or examples/.

Deliberately not changed

No default is introduced for either key. Omitting the header and letting the browser apply its own default keeps the upgrade behaviour-neutral, as the issue proposed.

One case I did not take a call on: strconv.ParseBool accepts 1, t, TRUE, so ACCESS_CONTROL_ALLOW_CREDENTIALS=1 passes validation but still emits Access-Control-Allow-Credentials: 1, which browsers ignore — the Fetch spec only recognises true. Tightening this to exactly true/false, or normalising to the canonical form, would close that last case, but it would start honouring credentials for configurations that are effectively disabled today. That felt like a security-relevant behaviour change to slip into this PR unasked. Happy to add it in a follow-up commit if you'd prefer.

Tests

config_test.go gains table-driven cases for values that are kept, values that are dropped and warned about, and the nil-logger path. Existing cases are unchanged apart from the new argument.

Checks run locally

  • gofmt -l — clean
  • go vet ./pkg/gofr/http/middleware/ ./pkg/gofr/ — clean
  • go test ./pkg/gofr/http/middleware/ -count=1 — pass
  • go test ./pkg/gofr/ -short -count=1 (factory call site) — pass
  • golangci-lint run — no findings in the changed files
  • go tool cover -func — 100% for both GetConfigs and isValidCORSValue

Docs updated in docs/advanced-guide/middlewares/page.md.


🤖 Generated with Claude Code

alanjollyc and others added 2 commits August 22, 2026 17:13
…tartup (gofr-dev#3941)

GetConfigs copied every ACCESS_CONTROL_* value into the CORS header map on a
non-empty check alone, and setMiddlewareHeaders wrote it to the response
verbatim. A value like `10m`, `600s`, `-1` or `abc` therefore produced a
malformed Access-Control-Max-Age header: browsers discard it and fall back to
their own preflight cache, so the caching the user configured silently never
happened and nothing in the logs said why. ACCESS_CONTROL_ALLOW_CREDENTIALS had
the same gap for non-boolean values.

Validate both keys as they are read. ACCESS_CONTROL_MAX_AGE must parse as a
non-negative number of seconds and ACCESS_CONTROL_ALLOW_CREDENTIALS as a
boolean; an invalid value is dropped and reported with a warning naming the
config key and the offending value, rather than emitted in a form the browser
would ignore. Keys without a defined value syntax are passed through unchanged.

GetConfigs had no logger, so it now takes the lean logging surface it needs -
the same injection the Logging middleware already uses - and factory.go passes
the container logger.

No default is introduced for either key: omitting the header and letting the
browser apply its own default keeps upgrades behaviour-neutral.

@Umang01-hash Umang01-hash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this — direction's right and the max-age half works well. Verified locally: with ACCESS_CONTROL_MAX_AGE=abc it now warns and drops the header instead of sending garbage.

Two things before merge:

  1. The credentials check is too loose. strconv.ParseBool accepts 1, t, True, TRUE — but browsers honor only the exact string true. Since we store the value verbatim, ACCESS_CONTROL_ALLOW_CREDENTIALS=1 passes validation and goes out as Access-Control-Allow-Credentials: 1, which the browser silently discards — the exact failure this PR is meant to catch. Confirmed by booting examples/http-server with =1: no warning, header sent as-is. Please tighten to val == "true" and add a case like "1" or "TRUE" to the invalid-value test (right now it only covers "yes").

  2. Changing the exported GetConfigs signature is a breaking API change — external callers of middleware.GetConfigs won't compile. Can we make the logger variadic (logger ...configLogger) so existing callers keep working?

Minor, not blockers: Atoi also accepts +600 / 0600 for max-age, and =false is emitted as : false (harmless but pointless — could just omit).

Comment thread pkg/gofr/http/middleware/config.go Outdated
Comment thread pkg/gofr/http/middleware/config.go Outdated
…nfigs backward compatible

Review feedback on gofr-dev#4052:

- ACCESS_CONTROL_ALLOW_CREDENTIALS is now matched against the literal "true".
  strconv.ParseBool also accepted 1, t and TRUE, which passed validation and were
  then emitted verbatim in a form the browser discards - the exact failure this
  change exists to catch. "false" is an explicit opt-out and is honored by omitting
  the header, so it is not reported as a misconfiguration.
- The logger argument is variadic, so existing callers of the exported GetConfigs
  keep compiling.
- ACCESS_CONTROL_MAX_AGE additionally requires a canonical decimal value: Atoi
  alone admitted "+600" and "0600", which were stored and sent verbatim.
- isValidCORSValue becomes shouldEmitCORSHeader, which describes all three outcomes
  now that a valid value can still be omitted.

Tests cover "1", "TRUE", "+600" and "0600" as invalid, "false" as omitted without a
warning, and GetConfigs called with no logger argument.
@alanjollyc

Copy link
Copy Markdown
Author

Thanks for the careful review, and for merging development in — I rebased on top of that rather than force-pushing over it.

Both blockers are addressed (details in the threads):

  1. Credentials — now matched against the literal true. 1, t, TRUE and yes warn and drop; false omits the header without a warning, since that is an explicit opt-out rather than a mistake. 1 and TRUE added to the invalid-value test.
  2. SignatureGetConfigs(c config.Config, logger ...configLogger), so external callers keep compiling. New test calls it with no logger argument.

I also took both of your minor notes, since leaving a known hole after it had been pointed out seemed worse than a slightly larger diff:

  • +600 / 0600 — max-age now requires a canonical decimal value (val == strconv.Itoa(seconds)), so those two are dropped and warned about instead of being sent verbatim. Both are in the invalid-value table. Say the word if you'd rather keep this PR to the original scope and I'll pull it back out.
  • =false emitted as : false — no longer emitted, as above.

One rename worth flagging: isValidCORSValue became shouldEmitCORSHeader, because a valid value (false) can now legitimately be omitted, and the old name no longer described the three outcomes. Also corrected a honours -> honors slip to match the American English convention.

Verified locally on the rebased branch: go vet clean, golangci-lint reports nothing in the changed files, go test ./pkg/gofr/http/middleware/ and the ./pkg/gofr/ call-site tests pass, and go tool cover -func still shows 100% for GetConfigs and shouldEmitCORSHeader (package coverage 91.5% -> 91.6%).

Ready for another look whenever you have a moment.

@Umang01-hash Umang01-hash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The credentials and max-age checks look right, and making GetConfigs variadic keeps it backward-compatible — nice call. Just lint left: goconst on the repeated "true" (it only counts config.go, tests are excluded). Two suggestions below and it's green.

Comment thread pkg/gofr/http/middleware/config.go
Comment thread pkg/gofr/http/middleware/config.go Outdated
@Umang01-hash

Copy link
Copy Markdown
Member

Hey @alanjollyc Can you please resolve the review comments as well as the merge conflicts. Everything except looks good to me!

Review feedback on gofr-dev#4052: pull "true" into allowCredentialsTrue and compare
against that, so the one value Access-Control-Allow-Credentials accepts is
named rather than left as a bare literal.
Resolves a docs conflict in advanced-guide/middlewares: gofr-dev#3770 rewrote the CORS
configuration section, so the validation note moved in alongside the new rules
for how values are applied, and now says the values are checked as the
configuration is read - the paragraph on constructing middleware.CORS yourself
still describes an unvalidated path.
@alanjollyc

Copy link
Copy Markdown
Author

Rebased-free update: development had moved on and the branch went into conflict, so I merged development in (same approach you used earlier, rather than force-pushing over your merge commit). Back to mergeable, 0 behind.

The only conflict was in docs/advanced-guide/middlewares/page.md#3770 rewrote that CORS section. I kept your new text intact and moved the validation note in alongside the rules for how values are applied, reworded to say the values are checked as the configuration is read, so it doesn't contradict the closing paragraph about constructing middleware.CORS(map[string]string{...}) yourself, which is still an unvalidated path.

Re-verified on the merged branch: go vet clean, golangci-lint clean on the changed files, go test ./pkg/gofr/http/middleware/ green against the new CORS/tracer tests from #3770, and 100% coverage still on GetConfigs and shouldEmitCORSHeader.

Unrelated FYI while I was in that file: the new paragraph says "Two rules govern how the values are applied" and then lists three bullets. Left it alone since it isn't mine to change.

@Umang01-hash Umang01-hash left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Verified locally at af66a7b: canonical Max-Age check correctly rejects +600/0600/-1/10m/600s and accepts 0/600, credentials matches only literal true (false omitted without a warning), nil/absent logger is panic-safe, and the variadic keeps GetConfigs backward-compatible (only factory.go calls it, updated here). Mutation-tested the guard to confirm the tests aren't vacuous, ran the http-server example with bad values and saw both WARN logs at startup + both headers dropped on the wire while a valid Allow-Origin passed through. gofmt/vet/build/golangci all clean.

Two nits, neither blocking.

// GetConfigs reads the middleware configuration from c. CORS values with a defined
// syntax are validated; an invalid one is dropped and reported through the optional
// logger instead of being emitted as a malformed response header.
func GetConfigs(c config.Config, logger ...configLogger) Config {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Behavior change worth a release note: configs that previously set a non-canonical value (1, TRUE, +600, 0600) were passed through as-is and are now dropped. It's toward correctness since browsers already discarded them, just flagging for the changelog.

// an invalid value is dropped and reported rather than sent — left in place it is
// invisible in the logs and looks present in the response. Keys without a defined
// value syntax are emitted unchanged.
func shouldEmitCORSHeader(key, val string, logger configLogger) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These comments run a bit long. The rationale is genuinely non-obvious so I'd keep the gist, but could trim to a line or two.

@aryanmehrotra aryanmehrotra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. I ran this against a live server rather than reading the diff, and both halves behave exactly as described.

The bug reproduces on the wire. Booting the same app with ACCESS_CONTROL_MAX_AGE=10m and ACCESS_CONTROL_ALLOW_CREDENTIALS=1, then sending a real preflight:

development this PR
Access-Control-Max-Age 10m omitted
Access-Control-Allow-Credentials 1 omitted
warnings at startup none two, each naming the key and the offending value
Access-Control-Allow-Origin * *, untouched

Both value tables, checked against a running server rather than the unit tests:

ACCESS_CONTROL_MAX_AGE600 and 0 are emitted with no warning; -1, +600, 0600, 10m, 600s, abc and a value that overflows int are all dropped and warned. The canonical-form check earns its keep: +600 and 0600 do parse with Atoi and would have gone out in a form the Fetch standard does not define.

ACCESS_CONTROL_ALLOW_CREDENTIALStrue is emitted; false is dropped silently, which is right since an absent header and false mean the same thing to a browser; TRUE, True, 1, t, yes and 0 are dropped and warned.

On the security question you raised in the description — the concern was that tightening this might start honouring credentials for configurations that are effectively disabled today. It does not, and that is worth stating plainly since it was the one thing that could have made this risky: 1 and TRUE were already discarded by the browser, so credentials were off before and are off after. The only change is that the operator now finds out. Reading the final code, you did take that call and landed on the literal true, which is the correct one, but the PR description still says you left it open — worth a quick edit so the description matches what merged.

check result
middleware suite pass, coverage 95.3% → 95.4%
GetConfigs / shouldEmitCORSHeader 100% each
fail-on-revert neutering the guard fails 7 subtests, so the tests are not vacuous
merge with current development 0 conflicts, whole repo builds, pkg/gofr green
gofmt / go vet clean

The variadic is genuinely non-breaking, and I checked it from outside the package rather than assuming. A caller compiled against GetConfigs(c) still builds, and GetConfigs(c, myLogger{}) also builds even though configLogger is unexported, since Go matches the argument structurally. Only factory.go calls it in-repo. I also confirmed there is no coupling on the dropped entry: setMiddlewareHeaders writes the fixed headers verbatim and branches on nothing, so omitting the credentials header has no knock-on effect on origin handling.

One nit, not blocking and not a CI problem. A full golangci-lint run over the package now reports goconst for ACCESS_CONTROL_ALLOW_ORIGIN: development has two occurrences, and the new case at config_test.go:133 makes it three against a threshold of three. CI will not catch it and should not fail — the workflow lints with only-new-issues: true, the finding anchors to a line this PR does not touch, and running the equivalent --new-from-rev=origin/development gives zero issues. Since you already added keyAccessControlMaxAge and keyAccessControlAllowCredentials, folding the origin key into the same set would keep a full-repo lint quiet and the constants consistent. Fine to leave.

Worth flagging separately that only the Snyk check has run on this head — no Go CI has executed here, so the results above are from running it locally.

The docs change matches the implemented behaviour, including the false case. Good fix.

@aryanmehrotra aryanmehrotra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Withdrawing my approval — I checked the -1 case against the browser implementation after approving, and it turns out to be doing something deliberate that this PR silently takes away. Everything else in my earlier review stands.

-1 is a working idiom, not a malformed value. From Chromium's services/network/cors/preflight_result.cc:

base::TimeDelta ParseAccessControlMaxAge(const std::optional<std::string>& max_age) {
  if (!max_age)                                 return kDefaultTimeout;    // absent -> 5s
  int64_t seconds;
  if (!base::StringToInt64(*max_age, &seconds)) return kDefaultTimeout;    // garbage -> 5s
  if (seconds < 0)                              return base::TimeDelta();  // -1 -> 0, never cached
  if (seconds >= kMaxTimeout.InSeconds())       return kMaxTimeout;        // cap 2h
  return base::Seconds(seconds);
}

A negative value returns a zero duration, which means the preflight result is not cached at all. So ACCESS_CONTROL_MAX_AGE=-1 is how you say "preflight every request", and people do use it that way while a CORS policy is in flux.

Dropping the header does not preserve that. An absent header takes the first branch and Chromium applies its 5 second default, so the effect of this PR on a -1 configuration is:

Chromium preflight cache
before, -1 sent 0s, preflight on every request
after, header dropped 5s

That is a behaviour change in the opposite direction from the one this PR is written to prevent, and the warning tells the operator they misconfigured something they had actually set on purpose.

The fix is small, because the PR already accepts the right answer. Reading the same function, 0 and -1 are equivalent: seconds == 0 falls through to base::Seconds(0), which is the same zero duration as the negative branch returns. 0 is also spec-valid — MDN defines the value as an unsigned non-negative integer — and this PR already accepts it, as I confirmed on the wire.

So there is no need to start accepting -1. What is needed is that the operator is told where to go. Two options, either is fine:

  • Keep rejecting -1, and special-case the message for negative values to name 0 as the spec-valid way to disable caching, rather than the generic "expected a non-negative number of seconds".
  • Or accept -1 and normalise it to 0 on the way out, which preserves intent without emitting an off-spec value.

Whichever you pick, the -1 behaviour is worth a line in docs/advanced-guide/middlewares/page.md next to the note this PR already adds, since the docs now tell people the value must be non-negative without saying what to use instead.

None of this touches the credentials half, which is exactly right and which I would rather not hold up. Worth recording that it matches the normative guidance word for word — MDN's directives section for Access-Control-Allow-Credentials reads "This is the only valid value for this header and is case-sensitive. If you don't need credentials, omit this header entirely rather than setting its value to false." This PR does both of those things independently, including the silent omission for false, so that half needs no changes at all.

The rest of my earlier review is unchanged: the malformed cases are handled correctly, the tests are not vacuous, the variadic keeps GetConfigs source-compatible for external callers, and coverage is up. Happy to re-approve as soon as the -1 path is settled.

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.

CORS: invalid ACCESS_CONTROL_MAX_AGE is passed through unvalidated with no warning

3 participants