Skip to content
Open
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
110 changes: 100 additions & 10 deletions pkg/db/sql_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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))
PY

Repository: openshift-hyperfleet/hyperfleet-api

Length of output: 25060


Skip identifier operands in typed-value validation.

validateTypedOperand already catches non-literal RHS operands by comparing identifiers to the tracked field, so "1 < created_time" and "not-a-date" < created_time remain rejected as intended. But the mirrored call also makes created_time < updated_time fail because the second operand is an identifier, not a literal. Add a guard such as if value.Kind == tsl.KindIdentifier { return nil } before the literal-value checks. As a reminder, CWE-20 input validation failures that reject valid input can reduce application availability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/db/sql_helpers.go` around lines 626 - 669, Update validateTypedValue to
return nil when value.Kind is tsl.KindIdentifier before performing literal-kind
validation, allowing comparisons between tracked typed fields while preserving
existing validation for literals, arrays, nulls, and invalid non-literal
operands.


// 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 {
Expand Down
114 changes: 114 additions & 0 deletions pkg/db/sql_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,120 @@ func TestFieldNameWalk_NumericCast(t *testing.T) {
})
}

// TestFieldNameWalk_TypedFieldValidation verifies that typed top-level columns
// (generation: INTEGER; created_time/updated_time/deleted_time: TIMESTAMPTZ)
// reject mismatched values with a clean 400 before ever reaching Postgres.
func TestFieldNameWalk_TypedFieldValidation(t *testing.T) {
tests := []struct {
name string
searchQuery string
errorContains string
expectError bool
}{
{
name: "generation with valid integer",
searchQuery: "generation = 5",
},
{
name: "generation with non-integer string value",
searchQuery: "generation = 'abc'",
expectError: true,
errorContains: "field 'generation' expects an integer",
},
{
name: "created_time with valid RFC3339 timestamp",
searchQuery: "created_time > '2026-01-01T00:00:00Z'",
},
{
name: "created_time with non-timestamp string value",
searchQuery: "created_time = 'not-a-date'",
expectError: true,
errorContains: "field 'created_time' expects an RFC3339 timestamp",
},
{
name: "updated_time with non-timestamp string value",
searchQuery: "updated_time = 'not-a-date'",
expectError: true,
errorContains: "field 'updated_time' expects an RFC3339 timestamp",
},
{
name: "deleted_time with non-timestamp string value",
searchQuery: "deleted_time = 'not-a-date'",
expectError: true,
errorContains: "field 'deleted_time' expects an RFC3339 timestamp",
},
{
name: "generation IN list with a non-integer element",
searchQuery: "generation IN [1, 'abc']",
expectError: true,
errorContains: "field 'generation' expects an integer",
},
{
name: "generation IN list with all integers",
searchQuery: "generation IN [1, 2, 3]",
},
{
name: "combined query: typed field valid, text field untouched",
searchQuery: "generation = 1 AND name = 'anything-goes'",
},
{
name: "combined query: error surfaces even when other predicate is valid",
searchQuery: "name = 'ok' AND generation = 'abc'",
expectError: true,
errorContains: "field 'generation' expects an integer",
},
{
name: "generation with literal-first comparison (field on right)",
searchQuery: "'abc' = generation",
expectError: true,
errorContains: "field 'generation' expects an integer",
},
{
name: "created_time with literal-first comparison (field on right)",
searchQuery: "'not-a-date' < created_time",
expectError: true,
errorContains: "field 'created_time' expects an RFC3339 timestamp",
},
{
name: "generation with literal-first comparison and valid value",
searchQuery: "1 < generation",
},
{
name: "deleted_time IS NULL is accepted (soft-delete filter)",
searchQuery: "deleted_time IS NULL",
},
{
name: "deleted_time IS NOT NULL is accepted (soft-delete filter)",
searchQuery: "deleted_time IS NOT NULL",
},
{
name: "created_time with valid date literal (no time component)",
searchQuery: "created_time > '2026-01-01'",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
RegisterTestingT(t)

tree, err := tsl.ParseTSL(tt.searchQuery)
Expect(err).ToNot(HaveOccurred())

_, serviceErr := FieldNameWalk(tree.Node)

if tt.expectError {
Expect(serviceErr).ToNot(BeNil())
Expect(serviceErr.Error()).To(ContainSubstring(tt.errorContains))
// No raw driver details should ever leak into the message.
Expect(serviceErr.Error()).ToNot(ContainSubstring("pq:"))
Expect(serviceErr.Error()).ToNot(ContainSubstring("SQLSTATE"))
return
}
Expect(serviceErr).To(BeNil())
})
}
}

func TestConditionStatusValidation(t *testing.T) {
tests := []struct {
status string
Expand Down
9 changes: 7 additions & 2 deletions pkg/handlers/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ import (
"time"
"unicode/utf8"

"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi"
"github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors"
)

const (
maxReasonLength = 1024
maxLabelKeyLen = 317 // 253-char prefix + "/" + 63-char name
maxReasonLength = 1024
// The Kubernetes label key spec allows up to 317 chars (253-char prefix + "/" + 63-char
// name), but resource_labels.key is VARCHAR(255) — cap at the DB limit so an
// oversized-but-K8s-valid key fails here with a clean 400 instead of during resource
// conversion with a 500 (api.ValidateLabel / api.MaxLabelKeyLen enforce the same bound).
maxLabelKeyLen = api.MaxLabelKeyLen
maxLabelValueLen = 63
maxObservedTimeSkew = 5 * time.Minute // tolerance for clock skew between adapter pods and API server
maxObservedTimeAge = 30 * time.Minute // matches Sentinel staleness health check window
Expand Down
21 changes: 21 additions & 0 deletions pkg/handlers/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,27 @@ func TestValidateLabels_InvalidValues(t *testing.T) {
}
}

// TestValidateLabels_KeyExceedsDBColumnLimit exercises the maxLabelKeyLen bound
// (see its doc comment) with a key that is K8s-spec-valid but DB-column-oversized.
func TestValidateLabels_KeyExceedsDBColumnLimit(t *testing.T) {
RegisterTestingT(t)

seg := strings.Repeat("a", 62)
prefix := strings.Join([]string{seg, seg, seg, seg}, ".") // 4*62 + 3 = 251 chars, each segment valid DNS-1123
name := strings.Repeat("b", 63)
key := prefix + "/" + name // 251 + 1 + 63 = 315 chars: valid K8s key, exceeds VARCHAR(255)
Expect(len(key)).To(Equal(315))
Expect(isValidLabelKeyPrefix(prefix)).To(BeTrue(), "prefix must be spec-valid for this test to be meaningful")

labels := map[string]string{key: "valid-value"}
req := openapi.ClusterCreateRequest{Labels: &labels}
validator := validateLabels(&req, "Labels")
err := validator()

Expect(err).ToNot(BeNil(), "expected an oversized-but-K8s-valid key to fail validation")
Expect(err.Reason).To(ContainSubstring("label validation failed"))
}

func TestValidateLabels_NilLabels(t *testing.T) {
RegisterTestingT(t)

Expand Down
13 changes: 8 additions & 5 deletions pkg/services/generic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ func TestSQLTranslation(t *testing.T) {
"search": "= = =",
"error": errors.CodeBadRequest + ": Failed to parse search query: = = =",
},
// "properties." was a leftover alias from before the resources table's JSONB
// column was renamed to "spec" — there is no "properties" column, so this must
// be rejected as an unknown field rather than translated into a query against a
// nonexistent column (which would surface as a raw pq: error at execution time).
{
"search": "properties.owner = 'team_a'",
"error": errors.CodeBadRequest + ": properties.owner is not a valid field name",
},
}
for _, test := range errorTests {
var list []api.Resource
Expand Down Expand Up @@ -132,11 +140,6 @@ func TestSQLTranslation(t *testing.T) {
"sql": "spec->'release'->>'version' = ?",
"values": ConsistOf("2"),
},
{
"search": "properties.owner = 'team_a'",
"sql": "properties ->> 'owner' = ?",
"values": ConsistOf("team_a"),
},
}
for _, test := range jsonbRelatedTableTests {
var list []api.Resource
Expand Down
Loading