Skip to content

fix(validate): parse numeric string constraints with strconv.ParseFloat - #1728

Merged
ernado merged 1 commit into
ogen-go:mainfrom
shuvamk:fix/string-numeric-parse
Aug 7, 2026
Merged

fix(validate): parse numeric string constraints with strconv.ParseFloat#1728
ernado merged 1 commit into
ogen-go:mainfrom
shuvamk:fix/string-numeric-parse

Conversation

@shuvamk

@shuvamk shuvamk commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Cross-type numeric constraints on strings (type: string with minimum/maximum, interpreted by default since #1580) accept values that are not numbers.

Spec:

PayReq:
  type: object
  required: [amount]
  properties:
    amount:
      type: string
      minimum: 1
      maximum: 10

Generated server (go run ./cmd/ogen, stock options), POST /pay:

request body expected actual on main
{"amount":"5abc"} 400 200, handler receives "5abc"
{"amount":"5,000"} 400 200, handler receives "5,000"
{"amount":"NaN"} 400 200, handler receives "NaN"
{"amount":"nan"} 400 200, handler receives "nan"
{"amount":"11"} 400 400 (correct)
{"amount":"abc"} 400 400, but the message reads parsing "" — the value is gone

Inf escapes a one-sided bound the same way. With only minimum: 1, "Inf" and "Infinity" validate; with only maximum: 10, "-Inf" validates.

Cause

validate/string.go:171:

var val float64
if _, err := fmt.Sscanf(v, "%f", &val); err != nil {
	return errors.Wrap(err, "parse as number")
}

fmt.Sscanf with %f stops at the first byte that cannot continue a float and returns successfully with whatever it managed to read; the rest of the input is discarded and not reported. "5abc" parses as 5, "5,000" as 5, "1.2.3" as 1.2. It also accepts NaN and Inf, and NaN compares false against both < and >, so it defeats the very bounds it was parsed for.

The value loss in the error message has the same root: for "abc" the verb consumes nothing, so strconv.ParseFloat is handed the empty string.

Introduced in ad9d3c73 (feat: enable cross-type constraint interpretation by default), whose own commit message states the intent as "Parses string as float64, validates numeric bounds" — so this is a bug, not a deliberate choice. git tag --contains ad9d3c73v1.17.0 … v1.23.0, and it is an ancestor of v1.23.0, so every release since v1.17.0 is affected.

Nothing covered validateNumeric: git grep -nE "Numeric|Sscanf" main -- '*_test.go' returns no hits. jsonschema/cross_type_constraints_test.go is parser-side only and asserts that Maximum survives parsing; it pins no runtime behaviour.

Fix

strconv.ParseFloat, which requires the whole string to be consumed, then reject non-finite results:

val, err := strconv.ParseFloat(v, 64)
if err != nil {
	return errors.Wrap(err, "parse as number")
}
if math.IsNaN(val) {
	return errors.Errorf("value %f is not a number", val)
}
if math.IsInf(val, 0) {
	return errors.Errorf("value %f is infinite", val)
}

The two guards are copied from Float.Validate (validate/float.go:71-79), which already rejects NaN and Inf with exactly this wording.

One file, +10/−4, plus the test. make generate examples produces a 0-file diffvalidate is a runtime library, so check-generate is unaffected.

Two things worth disclosing

1. This narrows behaviour slightly beyond the bug. Sscanf skipped leading and trailing whitespace, so " 5", "5 " and " 12 " were accepted and are now rejected. If you would rather keep those working, strings.TrimSpace(v) before ParseFloat is a one-line change and I am happy to switch — the two test rows LeadingSpace/TrailingSpace are the only ones that flip.

I ran an old-vs-new differential sweep before claiming this is otherwise safe: 55 hand-picked inputs plus 3,000,000 random strings of length 1–8 over the alphabet 0-9 + - . _ , e E x X p P a A b B c C n N i I f F, space and tab. Result: 0 newly accepted, 0 changed values, 657,226 newly rejected — every difference is in the reject-more direction. Unchanged accepts include +5, .5, 5., 007, 1e5, 0x1p-2, 1_0. Newly rejected beyond the cases above: "5%", "1.2.3", "0b101", "0o17", "1p5" (Sscanf reads these as 5, 1.2, 0, 0 and 32).

2. There is an in-repo inconsistency that cuts against the finite check. Float.ValidateStringified (validate/float.go:82) deliberately permits NaN and Inf — d2d85ae4, "Allow NaN and infinity". That is a different method for a different case (type: number transported as a string, where the JSON decoder produced a real float), and Float.Validate is the in-package precedent for rejecting non-finite, which is why I followed it here. But if you prefer consistency with ValidateStringified, say so and I will drop the two guards and keep only the trailing-garbage fix. Note that dropping them leaves NaN defeating both bounds, which is the more severe half of the report.

Tests

TestString_ValidateNumeric in validate/string_test.go, a table test in the shape of the neighbouring TestFloat_Validate. 23 cases across three validator configurations (min+max, min-only, max-only), covering both directions: the six accepts guard against over-narrowing, and the min-only/max-only rows are what pin the Inf behaviour that a two-sided bound would mask.

Verified the test fails without the fix. With validate/string.go reverted to main and the test kept:

--- FAIL: TestString_ValidateNumeric (0.00s)
    --- FAIL: TestString_ValidateNumeric/TrailingGarbage (0.00s)
    --- FAIL: TestString_ValidateNumeric/GroupSeparator (0.00s)
    --- FAIL: TestString_ValidateNumeric/SecondDot (0.00s)
    --- FAIL: TestString_ValidateNumeric/LeadingSpace (0.00s)
    --- FAIL: TestString_ValidateNumeric/TrailingSpace (0.00s)
    --- FAIL: TestString_ValidateNumeric/NaN (0.00s)
    --- FAIL: TestString_ValidateNumeric/NaNLower (0.00s)
    --- FAIL: TestString_ValidateNumeric/PosInfMinOnly (0.00s)
    --- FAIL: TestString_ValidateNumeric/PosInfinityMinOnly (0.00s)
    --- FAIL: TestString_ValidateNumeric/NaNMinOnly (0.00s)
    --- FAIL: TestString_ValidateNumeric/NegInfMaxOnly (0.00s)
    --- FAIL: TestString_ValidateNumeric/NaNMaxOnly (0.00s)
FAIL	github.com/ogen-go/ogen/validate	0.517s

12 of 23 subtests fail; restoring the fix turns them all green. The same generated server used for the table above now returns 400 for every bad row and still returns 200 for {"amount":"5"}.

Local gates

Run on go1.25.10 darwin/arm64, CGO_ENABLED=0, caches cleared. Baseline on main @ 2e755d12 was identical (36 ok, 0 failures) — no pre-existing red.

Command Result
go build ./... OK
go test --timeout 15m ./... 36 ok, 0 FAIL, 50 [no test files]
./go.test.sh (race) 0 failures, 0 data races
cd examples && go test ./... 15 ok, 0 FAIL
golangci-lint v2.12.2 run -c .golangci.yml ./... 0 issues
make generate examples + git diff 0-file diff

`String.validateNumeric` used `fmt.Sscanf(v, "%f", &val)`, which stops at the
first byte that cannot continue a float and discards the remainder without
reporting an error. It also accepts `NaN` and `Inf`, and `NaN` compares false
against both `<` and `>`, so it defeats the minimum and maximum checks it was
just parsed for.

For `amount: {type: string, minimum: 1, maximum: 10}` a generated server
returned 200 and passed the value to the handler:

    {"amount":"5abc"}   -> 200 (expected 400)
    {"amount":"5,000"}  -> 200 (expected 400)
    {"amount":"NaN"}    -> 200 (expected 400)
    {"amount":"nan"}    -> 200 (expected 400)

`Inf` escapes a one-sided bound the same way: with only `minimum` set, `Inf`
is accepted; with only `maximum` set, `-Inf` is accepted.

`Sscanf` also loses the value in the error it does produce: `"abc"` reported
`parsing ""` because `%f` consumed nothing before failing.

Replace it with `strconv.ParseFloat`, which requires the whole string to be
consumed, then reject non-finite results. `Float.Validate` already rejects
NaN and Inf the same way.

Introduced in ad9d3c7 (cross-type constraint interpretation, on by default),
released in v1.17.0 through v1.23.0. No test covered `validateNumeric`.

Behaviour narrowing to note: `Sscanf` skipped leading and trailing whitespace,
so `" 5"` and `"5 "` were accepted and are now rejected. `strings.TrimSpace`
before `ParseFloat` would keep them. A differential sweep over 3,000,000
generated inputs plus 55 hand-picked ones found every difference to be in the
reject-more direction: no input is newly accepted and no accepted input
changes value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ernado
ernado merged commit 9cba433 into ogen-go:main Aug 7, 2026
15 checks passed
@shuvamk
shuvamk deleted the fix/string-numeric-parse branch August 8, 2026 09:45
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