Guard check filter and row_filter against unsafe SQL (SELECT)#1303
Guard check filter and row_filter against unsafe SQL (SELECT)#1303SaptarshiAcharyya99 wants to merge 9 commits into
Conversation
|
Suggestion: I'd keep Why keep
Why destructive keywords should still be blocked (and this stays a simple change):
Tests should cover the destructive keywords:
Example of a - criticality: error
filter: "customer_id IN (SELECT customer_id FROM main.ref.active_customers)"
check:
function: is_not_null
arguments:
column: emailequivalently in code: DQRowRule(
check_func=check_funcs.is_not_null,
column="email",
criticality="error",
filter="customer_id IN (SELECT customer_id FROM main.ref.active_customers)",
)Could you also add an example like this — a filter that references another table — to the PR (and ideally the docs), so the intended "allow |
mwojtyczka
left a comment
There was a problem hiding this comment.
Inline notes to accompany the summary comment above: keep SELECT in filters (trusted-operator authored, and subqueries are a working, powerful feature), while keeping the destructive-keyword block and testing it.
| Raises: | ||
| UnsafeSqlQueryError: If the filter contains a forbidden statement such as SELECT. | ||
| """ | ||
| if filter_expr and not is_sql_query_safe(filter_expr, forbid_select=True): |
There was a problem hiding this comment.
I'd not forbid SELECT for filters here. A subquery in a filter is a working, powerful feature (inline referential/dynamic filters), and check filters are authored by trusted operators — the same principals who run the engine — so the confused-deputy read risk doesn't apply. Suggest dropping forbid_select=True and keeping only the destructive-keyword block. If you want a safety valve for deployments that load untrusted filters, expose forbid_select as an opt-in (default off) rather than always-on.
| ) | ||
| filter_condition = F.expr(row_filter) if row_filter else F.lit(True) | ||
| filter_condition = safe_filter_expr(row_filter) | ||
| median, mad = _calculate_median_absolute_deviation(df, col_expr_str, row_filter) |
There was a problem hiding this comment.
This passes the raw row_filter string into _calculate_median_absolute_deviation, which applies it via df.filter(<str>) — bypassing safe_filter_expr entirely, so even the destructive-keyword guard doesn't cover this path (has_no_outliers). Route it through safe_filter_expr (or pass the already-built filter_condition) so validation is consistent across all checks.
There was a problem hiding this comment.
Agree with passing filter_condition
| @@ -258,6 +259,32 @@ def test_mixed_content(): | |||
| assert not is_sql_query_safe("WITH cte AS (UPDATE users SET x=1) SELECT * FROM cte") | |||
|
|
|||
|
|
|||
| def test_select_blocked_when_forbid_select(): | |||
There was a problem hiding this comment.
Two coverage asks: (1) parametrize is_sql_query_safe over the full destructive set (delete, insert, update, drop, truncate, alter, create, replace, grant, revoke, merge, use, refresh, analyze, optimize, zorder), asserting each is rejected — not just select. (2) Since the recommendation is to keep SELECT allowed, add a test that a SELECT/subquery filter is allowed and runs end-to-end (e.g. customer_id IN (SELECT customer_id FROM ...)), rather than asserting it's blocked.
|
Thanks for the detailed review — this makes sense and I agree with the direction. Happy to keep Plan:
Pushing the updates shortly. |
ghanse
left a comment
There was a problem hiding this comment.
Looking good. I agree with the previous feedback. Left a few additional comments on the documentation.
| - `for_each_column`: (optional) list of column names or expressions for which the same check should be applied. When saving with `DQEngine.save_checks` to Delta or Lakebase tables, DQX stores such checks in compact format (one row per check, `for_each_column` preserved). | ||
| - (optional) `name`: name of the check: autogenerated if not provided. | ||
| - (optional) `filter`: spark expression to filter the rows for which the check is applied (e.g. `"business_unit = 'Finance'"`). The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. | ||
| - (optional) `filter`: spark expression to filter the rows for which the check is applied (e.g. `"business_unit = 'Finance'"`). The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. For security, `filter` and `row_filter` are restricted to simple predicates: they cannot contain a `SELECT` statement, and an unsafe filter raises an error. If you need arbitrary SQL (including subqueries), use the `sql_expression` check instead, and only accept check definitions from trusted sources in automated or multi-tenant pipelines. |
There was a problem hiding this comment.
Instead of saying "For security, filter and row_filter are restricted to simple predicates: they cannot contain a SELECT statement, and an unsafe filter raises an error.", I think we should just tell the user what is not allowed with a few examples:
"For security,
filterandrow_filtercannot contain unsafe DML keywords like (e.g.UPDATE,DELETE). An unsafe filter raises an error during execution.
| | └─ `for_each_column` | `array<string>` | (Optional) List of columns for which the same check applies. Stored in compact format (one row per check). Manual tables can use compact or expanded (individual rules instead of for_each_column) format. | | ||
| | └─ `arguments` | `map<string, string>` | Keyword arguments for the check function. All parameters without a default must be present (or supplied via `for_each_column` for `column` / `columns`). | | ||
| | `filter` | `string` | (Optional) Spark SQL expression to filter rows to which the check is applied, e.g. `"business_unit = 'Finance'"`. The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. | | ||
| | `filter` | `string` | (Optional) Spark SQL expression to filter rows to which the check is applied, e.g. `"business_unit = 'Finance'"`. The check function will run only on the rows matching the filter condition. The condition can reference any column of the validated dataset, not only the one where you apply the check function. When using dataset-level checks, the filter condition is pushed down as `row_filter` to the check function and applied before aggregation, ensuring that the check operates only on the relevant subset of rows rather than on the aggregated results. For security, `filter` and `row_filter` are restricted to simple predicates: they cannot contain a `SELECT` statement, and an unsafe filter raises an error. If you need arbitrary SQL (including subqueries), use the `sql_expression` check instead, and only accept check definitions from trusted sources in automated or multi-tenant pipelines.| |
| ) | ||
| filter_condition = F.expr(row_filter) if row_filter else F.lit(True) | ||
| filter_condition = safe_filter_expr(row_filter) | ||
| median, mad = _calculate_median_absolute_deviation(df, col_expr_str, row_filter) |
There was a problem hiding this comment.
Agree with passing filter_condition
| Raises: | ||
| UnsafeSqlQueryError: If the filter contains a forbidden statement such as SELECT. | ||
| """ | ||
| if filter_expr and not is_sql_query_safe(filter_expr, forbid_select=True): |
|
Thanks @mwojtyczka , @ghanse — addressed in the latest commit:
|
Changes
A check's
filter/row_filteraccepted arbitrary SQL. These are meant to be simple rowpredicates, but were compiled directly via
F.expr(...)with no validation — so a filterlike
id IN (SELECT ... FROM other_table)would run a subquery against another table withthe permissions of the process executing the checks. This is a risk in automated or
multi-tenant pipelines where check definitions come from a less-trusted source.
As discussed on the issue:
is_sql_query_safegains an optionalforbid_selectflag (defaultFalse), so thesql_querycheck — which requiresSELECT— is unaffected.safe_filter_exprvalidates a filter (raisingUnsafeSqlQueryErroron an unsafe one) and compiles it; used for both
filterandrow_filter.manager.py(check.filter),check_funcs.py(9row_filtersites), and one each inanomaly/scoring_utils.pyandgeo/check_funcs.py— so no site can slip through, and afuture site added via the helper is protected automatically.
sql_expressionis intentionally not keyword-blocked (subqueries can be legitimate);it gets a docstring security note instead, per the issue.
Word-boundary matching means identifiers like
select_flag/selectedare not false-flagged.Linked issues
Resolves #1089
Tests
Unit tests cover
is_sql_query_safe(forbid_select=True)(including the no-false-positivecase) and
safe_filter_exprraising onSELECT. End-to-end tests confirm a row-level checkand a dataset check each reject an unsafe filter through the real
DQRuleManager, plus asafe-filter-passes test.
Documentation and Demos
Security note added to the
filterfield inquality_checks_definition.mdx(bullet + table)and to the
sql_expressiondocstring.