Sanitize column names in list queries "order" and "expand" params.

This commit is contained in:
Sebastian Jeltsch
2025-03-27 14:17:15 +01:00
parent 4f8d43ab5b
commit f403265f6e
5 changed files with 89 additions and 43 deletions

View File

@@ -15,8 +15,8 @@ use crate::admin::AdminError as Error;
use crate::app_state::AppState;
use crate::constants::{LOGS_RETENTION_DEFAULT, LOGS_TABLE_ID_COLUMN};
use crate::listing::{
build_filter_where_clause, limit_or_default, parse_query, Cursor, Order, QueryParseResult,
WhereClause,
build_filter_where_clause, limit_or_default, parse_and_sanitize_query, Cursor, Order,
QueryParseResult, WhereClause,
};
use crate::table_metadata::{lookup_and_parse_table_schema, TableMetadata};
@@ -115,7 +115,7 @@ pub async fn list_logs_handler(
limit,
order,
..
} = parse_query(raw_url_query.as_deref())
} = parse_and_sanitize_query(raw_url_query.as_deref())
.map_err(|err| Error::Precondition(format!("Invalid query '{err}': {raw_url_query:?}")))?;
// NOTE: We cannot use state.table_metadata() here, since we're working on the logs database.

View File

@@ -8,8 +8,8 @@ use ts_rs::TS;
use crate::admin::AdminError as Error;
use crate::app_state::AppState;
use crate::listing::{
build_filter_where_clause, limit_or_default, parse_query, Cursor, Order, QueryParseResult,
WhereClause,
build_filter_where_clause, limit_or_default, parse_and_sanitize_query, Cursor, Order,
QueryParseResult, WhereClause,
};
use crate::records::sql_to_json::rows_to_json_arrays;
use crate::schema::Column;
@@ -41,7 +41,7 @@ pub async fn list_rows_handler(
order,
offset,
..
} = parse_query(raw_url_query.as_deref())
} = parse_and_sanitize_query(raw_url_query.as_deref())
.map_err(|err| Error::Precondition(format!("Invalid query '{err}': {raw_url_query:?}")))?;
let (table_metadata, table_or_view_metadata): (

View File

@@ -14,8 +14,8 @@ use crate::app_state::AppState;
use crate::auth::user::DbUser;
use crate::constants::{USER_TABLE, USER_TABLE_ID_COLUMN};
use crate::listing::{
build_filter_where_clause, limit_or_default, parse_query, Cursor, Order, QueryParseResult,
WhereClause,
build_filter_where_clause, limit_or_default, parse_and_sanitize_query, Cursor, Order,
QueryParseResult, WhereClause,
};
use crate::util::id_to_b64;
@@ -70,7 +70,7 @@ pub async fn list_users_handler(
limit,
order,
..
} = parse_query(raw_url_query.as_deref())
} = parse_and_sanitize_query(raw_url_query.as_deref())
.map_err(|err| Error::Precondition(format!("Invalid query '{err}': {raw_url_query:?}")))?;
let Some(table_metadata) = state.table_metadata().get(USER_TABLE) else {

View File

@@ -139,11 +139,20 @@ fn parse_bool(s: &str) -> Option<bool> {
};
}
#[inline]
fn sanitize_column_name(name: &str) -> bool {
// Assuming that all uses are quoted correctly, it should be enough to discard names containing
// (", ', `, [, ]), however we're conservative here.
return name
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_');
}
/// Parses out list-related query params including pagination (limit, cursort), order, and filters.
///
/// An example query may look like:
/// ?cursor=[0:16]&limit=50&order=price,-date&price[lte]=100&date[gte]=<timestamp>.
pub fn parse_query(query: Option<&str>) -> Result<QueryParseResult, String> {
pub fn parse_and_sanitize_query(query: Option<&str>) -> Result<QueryParseResult, String> {
let mut result: QueryParseResult = Default::default();
let Some(query) = query else {
return Ok(result);
@@ -159,33 +168,51 @@ pub fn parse_query(query: Option<&str>) -> Result<QueryParseResult, String> {
"cursor" => result.cursor = Cursor::parse(value.as_ref()),
"offset" => result.offset = value.parse::<usize>().ok(),
"count" => result.count = parse_bool(&value),
"expand" => result.expand = Some(value.split(",").map(|s| s.to_owned()).collect()),
"order" => {
let order: Vec<(String, Order)> = value
.split(",")
.map(|v| {
return match v {
x if x.starts_with("-") => (v[1..].to_string(), Order::Descending),
x if x.starts_with("+") => (v[1..].to_string(), Order::Ascending),
x => (x.to_string(), Order::Ascending),
};
})
.collect();
"expand" => {
result.expand = Some(
value
.split(",")
.map(|s| {
let column_name = s.to_owned();
result.order = Some(order);
if !sanitize_column_name(&column_name) {
return Err(column_name);
}
return Ok(column_name);
})
.collect::<Result<Vec<_>, _>>()?,
)
}
"order" => {
result.order = Some(
value
.split(",")
.map(|v| {
let col_order = match v.trim() {
x if x.starts_with("-") => (v[1..].to_string(), Order::Descending),
x if x.starts_with("+") => (v[1..].to_string(), Order::Ascending),
x => (x.to_string(), Order::Ascending),
};
if !sanitize_column_name(&col_order.0) {
return Err(col_order.0);
}
return Ok(col_order);
})
.collect::<Result<Vec<_>, _>>()?,
)
}
key => {
// Key didn't match any of the predefined list operations (limit, cursor, order), we thus
// assume it's a column filter. We try to split any qualifier/operation, e.g.
// Key didn't match any of the predefined list operations (limit, cursor, order, ...), we
// thus assume it's a column filter. We try to split any qualifier/operation, e.g.
// column[op]=value.
let Some((k, maybe_op)) = split_key_into_col_and_op(key) else {
return Err(key.to_string());
};
if !k
.chars()
.all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
{
if !sanitize_column_name(k) {
return Err(key.to_string());
}
@@ -289,6 +316,7 @@ lazy_static! {
mod tests {
use super::*;
use crate::util::id_to_b64;
use crate::util::urlencode;
#[test]
fn test_op_splitting_regex() {
@@ -309,8 +337,8 @@ mod tests {
#[test]
fn test_query_parsing() {
assert!(parse_query(None).is_ok());
assert!(parse_query(Some("")).is_ok());
assert!(parse_and_sanitize_query(None).is_ok());
assert!(parse_and_sanitize_query(Some("")).is_ok());
{
let cursor: [u8; 16] = [5; 16];
@@ -321,7 +349,7 @@ mod tests {
"limit=10&cursor={cursor}&order=%2bcol0,-col1,col2",
cursor = id_to_b64(&cursor)
);
let result = parse_query(Some(&query)).unwrap();
let result = parse_and_sanitize_query(Some(&query)).unwrap();
assert_eq!(result.limit, Some(10));
assert_eq!(result.cursor, Some(Cursor::Blob(cursor.to_vec())));
@@ -337,7 +365,7 @@ mod tests {
{
let query = Some("baz=23&bar[like]=foo");
let result = parse_query(query).unwrap();
let result = parse_and_sanitize_query(query).unwrap();
assert_eq!(
result.params.as_ref().unwrap().get("baz").unwrap(),
@@ -358,16 +386,22 @@ mod tests {
{
// foo,bar is an invalid key.
let query = Some("baz=23&foo,bar&foo_bar");
assert_eq!(parse_query(query).err(), Some("foo,bar".to_string()));
assert_eq!(
parse_and_sanitize_query(query).err(),
Some("foo,bar".to_string())
);
let query = Some("baz=23&foo_bar");
assert_eq!(parse_query(query).err(), Some("foo_bar".to_string()));
assert_eq!(
parse_and_sanitize_query(query).err(),
Some("foo_bar".to_string())
);
}
{
// Check whitespaces
let query = Some("foo=a+b&bar=a%20b");
let result = parse_query(query).unwrap();
let result = parse_and_sanitize_query(query).unwrap();
assert_eq!(
result.params.as_ref().unwrap().get("foo").unwrap(),
@@ -387,7 +421,7 @@ mod tests {
{
let query = Some("col_0[gte]=10&col_0[lte]=100");
let result = parse_query(query).unwrap();
let result = parse_and_sanitize_query(query).unwrap();
assert_eq!(
result.params.as_ref().unwrap().get("col_0"),
@@ -411,7 +445,7 @@ mod tests {
// Test both encodings: "+" and %20 for " ".
let value = "with+white%20spaces";
let query = Some(format!("text={value}"));
let result = parse_query(query.as_deref()).unwrap();
let result = parse_and_sanitize_query(query.as_deref()).unwrap();
assert_eq!(
result.params.as_ref().unwrap().get("text"),
@@ -424,5 +458,16 @@ mod tests {
result.params
);
}
{
// Sanitizing
assert!(
parse_and_sanitize_query(Some(&format!("order={}", urlencode("col'; inject")))).is_err()
);
assert!(
parse_and_sanitize_query(Some(&format!("expand={}", urlencode("col'; inject")))).is_err()
);
assert!(parse_and_sanitize_query(Some(&urlencode("col'; inject"))).is_err());
}
}
}

View File

@@ -11,7 +11,8 @@ use trailbase_sqlite::Value;
use crate::app_state::AppState;
use crate::auth::user::User;
use crate::listing::{
build_filter_where_clause, limit_or_default, parse_query, Order, QueryParseResult, WhereClause,
build_filter_where_clause, limit_or_default, parse_and_sanitize_query, Order, QueryParseResult,
WhereClause,
};
use crate::records::query_builder::Expansions;
use crate::records::sql_to_json::{row_to_json, row_to_json_expand, rows_to_json_expand};
@@ -62,14 +63,14 @@ pub async fn list_records_handler(
};
let QueryParseResult {
params: filter_params,
cursor,
limit,
order,
cursor,
count,
expand: query_expand,
order,
params: filter_params,
..
} = parse_query(raw_url_query.as_deref()).map_err(|_err| {
} = parse_and_sanitize_query(raw_url_query.as_deref()).map_err(|_err| {
return RecordError::BadRequest("Invalid query");
})?;