Direct answer: A reporting dashboard that builds SQL by concatenating filters into a WHERE clause is vulnerable to injection through any filter value the caller controls, and the fix for a BI-style query layer needs an extra layer beyond basic parameterization: the query STRUCTURE itself (which columns, how many filters) often varies dynamically, which plain parameterized values alone don't cover.
The risk. A dashboard filter UI (in a Looker-, Power BI-, or Tableau-style query layer) typically builds a WHERE clause by joining a list of active filters: WHERE region = 'US' AND category = 'Electronics'. If any filter value is inserted via string concatenation rather than a bound parameter, an attacker who controls a filter value (directly, or via a saved/shared dashboard link with query-string parameters) can break out of the intended clause the same way as any other SQL injection.
Safe alternatives, verified for the value-injection case and the column-allowlisting case together:
python
ALLOWED_COLUMNS = {"region", "amount"}
def safe_filter_query(conn, column, value):
if column not in ALLOWED_COLUMNS:
raise ValueError(f"unknown filter column: {column}")
sql = f"SELECT * FROM sales WHERE {column} = ?"
return conn.execute(sql, (value,)).fetchall()
I verified three cases by execution: a legitimate filter (region = 'US') returns the expected single row; an injection attempt through the VALUE (region = "US' OR '1'='1") returns nothing, because the value is always parameterized regardless of which column was chosen; and an injection attempt through the COLUMN NAME itself (a malicious identifier like region; DROP TABLE sales;--) is rejected with a ValueError before it ever reaches the database, because column identifiers can't be parameterized the same way values can - they have to be checked against an explicit allowlist.
Additional patterns for a BI query layer specifically:
- Parameterized queries for every filter value, exactly as above.
- Allowlisting for anything that varies the query's STRUCTURE (which columns are selectable, which tables are joinable) rather than just its values - this is the part a generic "always parameterize" rule misses, because a reporting tool's whole value proposition is letting users choose what to query, which means some part of the query text itself is dynamic by design.
- Escaping LIKE wildcards: for a "contains" or "starts with" text filter using
LIKE, the wildcard characters % and _ inside a legitimate search term need to be escaped (not to prevent injection, since the value is still parameterized, but to prevent a user's literal search for "50% off" from being silently interpreted as a wildcard pattern instead of a literal string). Concretely, using a single backslash as the SQL ESCAPE character:
python
def escape_like(term):
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
bound as WHERE name LIKE ? ESCAPE '\' with the parameter f"%{escape_like(term)}%". I verified this by execution against a real sqlite3 table (sqlite3 supports the standard LIKE ... ESCAPE clause): a legitimate search for "50% off" escapes to 50\% off (one backslash before the literal %) and, bound as the parameter, correctly matches the row containing "50% off". I also executed the tempting-but-wrong version that doubles every backslash a second time (term.replace("\\\\", "\\\\\\\\").replace("%", "\\\\%").replace("_", "\\\\_")) against the same table: it produces two backslashes before the % (50\\% off), and against ESCAPE '\' the database consumes the first backslash as an escaped literal backslash and reads the second as an unescaped wildcard again, so the query returns zero rows for the exact literal text it is supposed to match. The correct version escapes each special character exactly once; get the backslash count wrong and the mitigation silently breaks legitimate search instead of just failing to protect anything.
Trade-offs and pitfalls: the allowlist for selectable columns/tables needs to be maintained alongside the actual schema and the BI tool's exposed field list, or it either breaks legitimate new fields (too strict) or silently permits querying a field that was supposed to be hidden from this dashboard's audience (too loose, e.g. an internal cost field accidentally exposed through a generic "any column" filter). This is a genuinely harder problem than single-value parameterization, and it's worth treating the allowlist itself as security-sensitive configuration reviewed on the same cadence as an access-control policy, not as a one-time setup step.