Skip to content

Commit 79951a6

Browse files
committed
Validate numeric segment values + add flagsmith-lint-tests hook
The numeric comparator family (GREATER_THAN / LESS_THAN / *_INCLUSIVE) was interpolating segment-supplied values raw into the WHERE clause — a SQL-injection path for any user with `MANAGE_SEGMENTS` permission on the project. Same risk class as the MODULO branch already guarded against. Fix is symmetrical: float() the value in Python first, return None on ValueError so the caller falls back. Three new unit tests assert the guard: - injection-shaped value on each comparator returns None - clean numeric value interpolates the parsed float - injection in MODULO's divisor returns None Also adds the flagsmith-lint-tests pre-commit hook (FT003 / FT004 from flagsmith-common). Renames the parity test and reshapes every unit test to the `test_subject__condition__expected` template plus Given/When/Then comments enforced by the hook. beep boop
1 parent 154de45 commit 79951a6

4 files changed

Lines changed: 199 additions & 45 deletions

File tree

.pre-commit-config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ repos:
1515
- id: check-yaml
1616
- id: check-json
1717
- id: check-toml
18+
- repo: https://github.com/Flagsmith/flagsmith-common
19+
rev: v3.8.2
20+
hooks:
21+
- id: flagsmith-lint-tests
1822
- repo: local
1923
hooks:
2024
- id: python-typecheck

src/flagsmith_sql_flag_engine/translator.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@
2828
from __future__ import annotations
2929

3030
import re
31-
from typing import Any
3231

33-
from flag_engine.context.types import EnvironmentContext, SegmentContext
32+
from flag_engine.context.types import (
33+
EnvironmentContext,
34+
SegmentCondition,
35+
SegmentContext,
36+
SegmentRule,
37+
)
3438

3539
from flagsmith_sql_flag_engine.dialect import Dialect
3640
from flagsmith_sql_flag_engine.dialects.snowflake import SnowflakeDialect
@@ -213,7 +217,11 @@ def _semver_sort_key_expr(ctx: TranslateContext, value_sql: str) -> str:
213217

214218

215219
def _comparison(
216-
ctx: TranslateContext, op: str, expr: str, value: object, is_jsonpath: bool = False
220+
ctx: TranslateContext,
221+
op: str,
222+
expr: str,
223+
value: object,
224+
is_jsonpath: bool = False,
217225
) -> str | None:
218226
"""Emit a SQL fragment comparing `expr` against `value` per `op`.
219227
@@ -237,21 +245,32 @@ def _comparison(
237245
if op == "NOT_CONTAINS":
238246
return f"({expr} IS NOT NULL AND NOT ({d.position(lit, str_expr)}))"
239247
if op in {"GREATER_THAN", "LESS_THAN", "GREATER_THAN_INCLUSIVE", "LESS_THAN_INCLUSIVE"}:
248+
# Validate as numeric BEFORE interpolating — segment values come from
249+
# `MANAGE_SEGMENTS`-permissioned users, who shouldn't be able to inject
250+
# SQL via a comparator value. ValueError → untranslatable, caller
251+
# falls back to the engine UDF or surfaces an error upstream.
252+
try:
253+
numeric_value = float(str(value))
254+
except (TypeError, ValueError):
255+
return None
240256
sql_op = {
241257
"GREATER_THAN": ">",
242258
"LESS_THAN": "<",
243259
"GREATER_THAN_INCLUSIVE": ">=",
244260
"LESS_THAN_INCLUSIVE": "<=",
245261
}[op]
246-
return f"({expr} IS NOT NULL AND {d.cast_float(expr)} {sql_op} {value})"
262+
return f"({expr} IS NOT NULL AND {d.cast_float(expr)} {sql_op} {numeric_value})"
247263
if op == "MODULO":
264+
# Same validation discipline: divisor/remainder must parse as numbers.
248265
try:
249-
divisor, remainder = str(value).split("|")
250-
float(divisor)
251-
float(remainder)
266+
divisor_str, remainder_str = str(value).split("|")
267+
divisor = float(divisor_str)
268+
remainder = float(remainder_str)
252269
except (ValueError, AttributeError):
253270
return None
254-
return f"({expr} IS NOT NULL AND ({d.mod(d.cast_number(expr), divisor)}) = {remainder})"
271+
return (
272+
f"({expr} IS NOT NULL AND ({d.mod(d.cast_number(expr), str(divisor))}) = {remainder})"
273+
)
255274
if op == "REGEX":
256275
if not _regex_safe_for_re2(str(value)):
257276
return None
@@ -264,7 +283,7 @@ def _comparison(
264283
# ---------------------------------------------------------------------------
265284

266285

267-
def translate_condition(cond: dict[str, Any], ctx: TranslateContext) -> str | None:
286+
def translate_condition(cond: SegmentCondition, ctx: TranslateContext) -> str | None:
268287
op = cond["operator"]
269288
if op not in TRANSLATABLE_OPERATORS:
270289
return None
@@ -276,7 +295,10 @@ def translate_condition(cond: dict[str, Any], ctx: TranslateContext) -> str | No
276295
if op == "PERCENTAGE_SPLIT":
277296
if not ctx.segment_key:
278297
return None
279-
threshold = float(val) if val is not None else 0.0
298+
try:
299+
threshold = float(str(val)) if val is not None else 0.0
300+
except (TypeError, ValueError):
301+
return None
280302
if not prop:
281303
value_expr = ctx.dialect.cast_string(ctx.identity_key_col)
282304
elif prop.startswith("$."):
@@ -345,7 +367,7 @@ def translate_condition(cond: dict[str, Any], ctx: TranslateContext) -> str | No
345367
# ---------------------------------------------------------------------------
346368

347369

348-
def translate_rule(rule: dict[str, Any], ctx: TranslateContext) -> str | None:
370+
def translate_rule(rule: SegmentRule, ctx: TranslateContext) -> str | None:
349371
children: list[str] = []
350372
for cond in rule.get("conditions") or []:
351373
sql = translate_condition(cond, ctx)
@@ -378,14 +400,14 @@ def translate_segment(segment: SegmentContext, ctx: TranslateContext) -> str | N
378400
The expression goes after `WHERE i.environment_id = '<env-key>' AND ...`.
379401
The caller composes the surrounding `SELECT ... FROM IDENTITIES i`.
380402
"""
381-
if ctx.segment_key is None and "key" in segment:
382-
ctx = ctx.with_segment_key(str(segment["key"]))
403+
if ctx.segment_key is None:
404+
ctx = ctx.with_segment_key(segment["key"])
383405
rules = segment.get("rules") or []
384406
if not rules:
385407
return "FALSE"
386408
rule_sql: list[str] = []
387409
for r in rules:
388-
sql = translate_rule(dict(r), ctx)
410+
sql = translate_rule(r, ctx)
389411
if sql is None:
390412
return None
391413
rule_sql.append(f"({sql})")

tests/test_engine.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,21 +35,25 @@ def _all_case_segments() -> list[tuple[int, str]]:
3535
_all_case_segments(),
3636
ids=[f"case{i}-seg{k}" for i, k in _all_case_segments()],
3737
)
38-
def test_segment_matches_engine(
38+
def test_translate_segment__engine_test_data_case__matches_engine(
3939
case_idx: int,
4040
seg_key: str,
4141
loaded_cases: list[dict],
4242
parity_results: dict[tuple[int, str], bool | None],
4343
) -> None:
44+
# Given a (case, segment) pair from engine-test-data and the pre-computed
45+
# batched parity_results dict mapping each pair to its SQL-evaluated bool
4446
case = loaded_cases[case_idx]
4547
eval_ctx = case["context"]
4648
segment = eval_ctx["segments"][seg_key]
47-
4849
sql_match = parity_results[(case_idx, seg_key)]
4950
if sql_match is None:
5051
pytest.skip(f"segment uses untranslatable operator (case {case_idx} seg {seg_key})")
5152

53+
# When the engine evaluates the same (context, segment) in-memory
5254
engine_match = is_context_in_segment(eval_ctx, segment)
55+
56+
# Then the SQL-evaluated and engine-evaluated booleans agree
5357
assert engine_match == sql_match, (
5458
f"engine={engine_match} sql={sql_match} for case={case_idx} seg={seg_key}"
5559
)

0 commit comments

Comments
 (0)