-
Notifications
You must be signed in to change notification settings - Fork 24
HYPERFLEET-1375 - fix: validate typed search fields and close DB column leaks #308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,16 +37,6 @@ func validateJSONBKey(key, fieldType string) *errors.ServiceError { | |
| func getField(name string) (field string, err *errors.ServiceError) { | ||
| trimmedName := strings.Trim(name, " ") | ||
|
|
||
| if strings.HasPrefix(trimmedName, "properties.") { | ||
| key := strings.TrimPrefix(trimmedName, "properties.") | ||
| if validationErr := validateJSONBKey(key, "property key"); validationErr != nil { | ||
| err = validationErr | ||
| return | ||
| } | ||
| field = fmt.Sprintf("properties ->> '%s'", key) | ||
| return | ||
| } | ||
|
|
||
| // Map user-friendly spec.xxx (and nested spec.xxx.yyy...) syntax to JSONB query. | ||
| // v6 gives us the full dotted path directly: | ||
| // spec.region → spec->>'region' | ||
|
|
@@ -575,9 +565,109 @@ func FieldNameWalk(n *tsl.Node) (*tsl.Node, *errors.ServiceError) { | |
| } | ||
| return n, errors.BadRequest("%s", err.Error()) | ||
| } | ||
| if svcErr := validateTypedFieldValues(mapped); svcErr != nil { | ||
| return n, svcErr | ||
| } | ||
| return wrapSpecNumericCasts(mapped), nil | ||
| } | ||
|
|
||
| // searchTypedFields declares the expected TSL literal kind for top-level search | ||
| // columns whose underlying SQL type does not accept arbitrary text (INTEGER, | ||
| // TIMESTAMPTZ). Fields not listed here are VARCHAR and accept any string | ||
| // literal without a Postgres type-mismatch risk. | ||
| var searchTypedFields = map[string]tsl.Kind{ | ||
| "generation": tsl.KindNumericLiteral, | ||
| "created_time": tsl.KindTimestampLiteral, | ||
| "updated_time": tsl.KindTimestampLiteral, | ||
| "deleted_time": tsl.KindTimestampLiteral, | ||
| } | ||
|
|
||
| // typedKindHints gives a human-readable description of each tsl.Kind used in | ||
| // searchTypedFields, for use in validation error messages. | ||
| var typedKindHints = map[tsl.Kind]string{ | ||
| tsl.KindNumericLiteral: "an integer", | ||
| tsl.KindTimestampLiteral: "an RFC3339 timestamp (e.g. 2026-01-01T00:00:00Z)", | ||
| } | ||
|
|
||
| // validateTypedFieldValues walks binary comparisons against a searchTypedFields | ||
| // column — on either side of the operator, since TSL allows both orders (see | ||
| // wrapSpecNumericCasts below) — and rejects values whose TSL literal kind | ||
| // doesn't match the column's SQL type, before the query reaches Postgres as a | ||
| // raw pq: error. | ||
| func validateTypedFieldValues(n *tsl.Node) *errors.ServiceError { | ||
| if n == nil { | ||
| return nil | ||
| } | ||
| if n.Kind == tsl.KindBinaryExpr { | ||
| if err := validateTypedOperand(n.Left, n.Right); err != nil { | ||
| return err | ||
| } | ||
| if err := validateTypedOperand(n.Right, n.Left); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| if err := validateTypedFieldValues(n.Left); err != nil { | ||
| return err | ||
| } | ||
| if err := validateTypedFieldValues(n.Right); err != nil { | ||
| return err | ||
| } | ||
| for _, child := range n.Children { | ||
| if err := validateTypedFieldValues(child); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // validateTypedOperand checks whether ident is a tracked typed-field identifier | ||
| // and, if so, validates value against that field's expected TSL literal kind. | ||
| // It's a no-op when ident isn't a tracked identifier (e.g. it's the value side). | ||
| func validateTypedOperand(ident, value *tsl.Node) *errors.ServiceError { | ||
| if ident == nil || ident.Kind != tsl.KindIdentifier { | ||
| return nil | ||
| } | ||
| field, ok := ident.Value.(string) | ||
| if !ok { | ||
| return nil | ||
| } | ||
| wantKind, tracked := searchTypedFields[field] | ||
| if !tracked { | ||
| return nil | ||
| } | ||
| return validateTypedValue(field, wantKind, value) | ||
| } | ||
|
|
||
| // validateTypedValue checks a single value node (or, for IN queries, each | ||
| // element of an array literal) against the expected TSL literal kind. A date | ||
| // literal (no time component) is accepted wherever a timestamp is expected. | ||
| // null is always accepted, since typed columns here are nullable (e.g. | ||
| // deleted_time IS NULL / IS NOT NULL is a standard soft-delete filter). | ||
| func validateTypedValue(field string, wantKind tsl.Kind, value *tsl.Node) *errors.ServiceError { | ||
| if value == nil { | ||
| return errors.BadRequest("missing value for field '%s'", field) | ||
| } | ||
| if value.Kind == tsl.KindArrayLiteral { | ||
| for _, child := range value.Children { | ||
| if err := validateTypedValue(field, wantKind, child); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| if value.Kind == tsl.KindNullLiteral { | ||
| return nil | ||
| } | ||
| if value.Kind == wantKind || (wantKind == tsl.KindTimestampLiteral && value.Kind == tsl.KindDateLiteral) { | ||
| return nil | ||
| } | ||
| hint, ok := typedKindHints[wantKind] | ||
| if !ok { | ||
| hint = fmt.Sprintf("a value of kind %s", wantKind) | ||
| } | ||
| return errors.BadRequest("field '%s' expects %s", field, hint) | ||
| } | ||
|
Comment on lines
+626
to
+669
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== file ranges =="
wc -l pkg/db/sql_helpers.go 2>/dev/null || true
echo "== relevant sql_helpers.go section =="
sed -n '560,680p' pkg/db/sql_helpers.go
echo "== imports and tsl.Node reference =="
sed -n '1,40p' pkg/db/sql_helpers.go
echo "== tests around typed validation =="
fd -e go 'sql_helpers.*test|generic.*test' . | while read -r f; do
echo "--- $f"
rg -n "validateTyped|created_time|updated_time|identifier|KindIdentifier|field.*field|column" "$f" -C 3 || true
done
echo "== package go.mod for tsl deps =="
fd 'go.mod$' . -x sh -c 'echo "--- $1"; rg -n "github.com|module ' "$1" || true' sh {}Repository: openshift-hyperfleet/hyperfleet-api Length of output: 18091 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== search for field-to-field / column comparison tests =="
rg -n "created_time|updated_time|deleted_time|column[-_]?column|field[-_]?field|identifier" pkg/db/sql_helpers_test.go pkg/services/generic_test.go -C 4 || true
echo "== search for wrapSpecNumericCasts section to see generated SQL / type coercion =="
sed -n '669,720p' pkg/db/sql_helpers.go
echo "== run unit tests for sql_helpers typed validation if available =="
go test ./pkg/db -run 'TestFieldNameWalk_TypedFieldValidation' -v || true
echo "== static verifier for current validation behavior =="
python3 - <<'PY'
kinds = ["ArrayLiteral","NullLiteral","Identifier","NumericLiteral","TimestampLiteral","DateLiteral","StringLiteral"]
tracked = {"generated":"NumericLiteral","created_time":"TimestampLiteral","updated_time":"TimestampLiteral","deleted_time":"TimestampLiteral"}
def validate(v_kind,wanted):
if v_kind is None:
return "BadRequest missing value"
if v_kind == "ArrayLiteral":
return "recurse children"
if v_kind == "NullLiteral":
return "ok null"
if v_kind == wanted or (wanted=="TimestampLiteral" and v_kind=="DateLiteral"):
return "ok exact/date"
return "BadRequest expects hint"
for ident, wanted in {"created_time":"TimestampLiteral","updated_time":"TimestampLiteral","generation":"NumericLiteral"}.items():
print("tracked:", ident, wanted)
for rhs in kinds:
print(" ", rhs, "->", validate(rhs,wanted))
PYRepository: openshift-hyperfleet/hyperfleet-api Length of output: 25060 Skip identifier operands in typed-value validation.
🤖 Prompt for AI Agents |
||
|
|
||
| // wrapSpecNumericCasts wraps spec JSONB fields in CAST(... AS numeric) when | ||
| // compared against a number, so multi-digit values sort correctly. | ||
| func wrapSpecNumericCasts(n *tsl.Node) *tsl.Node { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.