Skip to content

Security: SQL injection prevention and input validation across filtering layer - #19

Open
Oluwabori19 wants to merge 14 commits into
digitalcityscience:mainfrom
Oluwabori19:ligfinder-security-checks-implementation
Open

Security: SQL injection prevention and input validation across filtering layer#19
Oluwabori19 wants to merge 14 commits into
digitalcityscience:mainfrom
Oluwabori19:ligfinder-security-checks-implementation

Conversation

@Oluwabori19

@Oluwabori19 Oluwabori19 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Parameterise all SQL queries across the filtering layer. Values are now bound via named placeholders (:p0, :p1, ...) and never interpolated into SQL strings
  • Validate all SQL identifiers (table names, column names) against a safe identifier regex before use; validate SQL operators against an explicit allowlist
  • Add input sanitisation and DoS limits to the criteria SQL builder. Oversized values, lists, and criteria counts are rejected with a clear 400 error rather than silently truncated
  • Fix a runtime crash in the parcel maximizer where generate_criteria_sql return tuple was not unpacked

What was changed

  • ligfinderFunc.py: Parameterised named placeholders, _sanitise_list, CriteriaLimitExceeded, configurable DoS limits; oversized values now raise instead of being silently dropped
  • database.py: Added params argument to execute_sql_query to enable parameter binding at execution
  • ligfinderModel.py: Added Group/CriteriaGroup models and = None defaults to prevent pre-validation crashes
  • ligfinder.py: Table name validated, UUIDs parameterised, criteria params correctly forwarded, metric/GRZ column and operator validated, values parameterised
  • parcel_maximizer.py: Tuple unpack crash fixed, dual WHERE clause for outer CTE and inner pgr_connectedComponents dollar-quoted block, all inputs secured
  • geometry_operations.py: Table name validated, GeoJSON geometry bound as named param
  • isochronesFuncs.py: Mode validated against explicit allowlist (used as table name, cannot be parameterised), coordinates and travel time bound as params
  • administrative.py: Column name validated, integer values parameterised, unused imports removed

Test plan

  • Run security test suite inside agora-dev-api container: pytest src/app/common/test_security_ligfinder.py -v
  • Run full behaviour test suite: pytest src/app/common/ -v
  • Verify /ligfinder/filter returns correct GeoJSON for a valid criteria request
  • Verify /ligfinder/maximizer returns clusters for a valid request
  • Verify /geometry/filter returns UUIDs for a valid spatial intersection request
  • Verify /geometry/isochrone returns a FeatureCollection for a valid mode/coordinate request
  • Verify /administrative/data/features/{id} returns features for a valid column filter
  • Confirm invalid table name, column name, or operator returns 400
  • Confirm SQL injection payload in a filter value returns 400

Oluwabori19 and others added 14 commits June 19, 2026 14:16
… them

_sanitise_list now raises CriteriaLimitExceeded when a value exceeds
max_value_length. Previously the value was silently dropped, which
produced a query that looked valid but reflected only part of the
user's filter — a correctness bug harder to detect than a visible error.
Callers catch CriteriaLimitExceeded and return a 400.
… binding

Without this, named placeholders (:p0, :p1) from the criteria SQL
builder could never be bound. they would reach the database as
literal text. params defaults to None so existing callers are unaffected.
… models

CriteriaGroup and Group support the new groups format in the filter
endpoint. Setting Optional fields to = None fixes a crash when geometry
or criteria are absent. previously len(None) would raise TypeError
before any security checks could run.
- Validate table_name against safe identifier regex before interpolation
- Bind geometry UUIDs as named params instead of raw string interpolation
- Fix criteria handling: call generate_criteria_sql with criteria list
  directly, unpack (sql, params) tuple, accumulate and forward all params
- Validate metric and GRZ column names and operators against allowlists
- Bind metric and GRZ values as named params
- Remove broken %(p0)s substitution loop that was a no-op
- Remove debug print statements leaking request data to stdout
- Fix runtime crash: generate_criteria_sql returns (sql, params) tuple
  but was assigned without unpacking, breaking every criteria request
- Validate table_name against safe identifier regex
- Build two parallel WHERE clauses: named params for the outer CTE,
  safely-escaped literals for the pgr_connectedComponents dollar-quoted
  block where bound params cannot reach
- Bind geometry UUIDs, criteria params, metric/GRZ values, and threshold
  as named params in the outer query
- Validate metric and GRZ column names and operators against allowlists
- Remove debug print statement
…injection

geometry_operations.py:
- Validate tableName against safe identifier regex before interpolation
- Bind GeoJSON geometry string as named param :geom instead of raw %s
  interpolation, user-derived geometry data no longer touches SQL
- Remove unused Session import

isochronesFuncs.py:
- Validate mode against explicit allowlist (walk_network, bike_network,
  drive_network) — mode is used as a table name in four positions
  including inside a pgr_drivingDistance string literal where named
  params cannot bind; the allowlist makes interpolation safe
- Bind lng, lat, and time as named params in the outer query
- Validate target_column.name against safe identifier regex — this field
  is user-supplied and was interpolated raw as a column name in the
  WHERE clause
- Bind target_value_list integers as named params :v0, :v1 ... instead
  of raw tuple interpolation (also fixes trailing-comma bug on single
  element tuples)
- Catch ValueError from validation and return 400 instead of 500
46 core behaviour tests covering empty inputs, single clauses,
combinations, key precedence, SQL structure, limits, and real-world
scenarios. 16 security tests covering SQL injection (8), Denial of
Service (3), and semantic gap handling (5). 32 edge case tests covering
malformed data, invalid structure, and boundary values.
_sanitise_list now raises instead of silently dropping oversized values.
The test was written before that change and checked for sql == ""; update
it to use pytest.raises(CriteriaLimitExceeded) to match the new behaviour.
parcel_maximizer.py: backslash escapes inside nested f-string expressions
are not allowed before Python 3.12. Extract the UUID quoting logic to a
plain variable to eliminate the nested f-string.

isochronesFuncs.py: the broad except Exception block was catching the
ValueError raised by mode allowlist validation and re-wrapping it as a
500, so the router ValueError handler never saw it. Add an explicit
except (ValueError, HTTPException): raise so validation errors reach
the router and return 400.
After rebasing on the remote conflict-resolution commits, the file ended
up with two execute_sql_query calls: our parameterised version (correct)
followed by the original no-params version (which overwrote the result
and bypassed parameter binding). Remove the duplicate.
- Changed bare `from ligfinderFunc import` to relative package imports
  so tests run correctly under pytest with the package structure
- Fixed all param-value assertions from `x in params` (key lookup) to
  `x in params.values()` (value lookup) to match the named-param dict
- Updated placeholder assertions from `"%s" in sql` to `":p" in sql`
  to reflect the named-parameter binding style (:p0, :p1, ...)
- Corrected undefined `result` variable references to `sql` in
  test_edge_cases_ligfinder error messages
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.

1 participant