fix: SQL injection in field filters, document search ranking, item search ordering

- Sanitize field-filter keys from query params before interpolating
  into JSON path expressions — prevents SQL injection via crafted
  query parameter names (affects both SQLite and PostgreSQL)
- Fix document search ORDER BY rank on PostgreSQL — the PG query
  path doesn't expose a `rank` column; use ts_rank() with DESC
- Fix item search rank ordering in ListItems and SearchItems for
  PostgreSQL — ts_rank() needs DESC (higher = more relevant)
This commit is contained in:
xarmian
2026-04-05 23:59:53 +00:00
parent 55101e9680
commit fa3aee6561
3 changed files with 41 additions and 3 deletions
+9 -1
View File
@@ -97,7 +97,15 @@ func (s *Store) ListDocuments(workspaceID string, params models.DocumentListPara
}
if params.Query != "" {
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
if s.dialect.Driver() == DriverPostgres {
// PostgreSQL ts_rank(): higher = more relevant → DESC
ftsRank := s.dialect.FTSRank("d", "search_vector")
query += fmt.Sprintf(" ORDER BY %s DESC, d.%s %s", ftsRank, sortCol, order)
args = append(args, params.Query) // extra placeholder for ts_rank
} else {
// SQLite FTS5: rank is a hidden column on the FTS JOIN (ascending = better)
query += fmt.Sprintf(" ORDER BY rank, d.%s %s", sortCol, order)
}
} else {
query += fmt.Sprintf(" ORDER BY pinned DESC, %s %s", sortCol, order)
}
+17 -2
View File
@@ -421,6 +421,11 @@ func (s *Store) ListItems(workspaceID string, params models.ItemListParams) ([]m
// Field filters — supports comma-separated values as OR
for key, value := range params.Fields {
// Sanitize the key to prevent SQL injection — field names must be
// alphanumeric/underscore only (user-controlled from query params).
if !isValidFieldKey(key) {
continue
}
jsonExpr := s.dialect.JSONExtractText("i.fields", key)
if strings.Contains(value, ",") {
values := strings.Split(value, ",")
@@ -513,7 +518,13 @@ func (s *Store) listItemsFTS(workspaceID string, params models.ItemListParams) (
args = append(args, params.CollectionSlug)
}
query += " ORDER BY " + ftsRank
// SQLite bm25(): more negative = more relevant → ASC (default).
// PostgreSQL ts_rank(): higher = more relevant → DESC.
if s.dialect.Driver() == DriverPostgres {
query += " ORDER BY " + ftsRank + " DESC"
} else {
query += " ORDER BY " + ftsRank
}
if params.Limit > 0 {
query += " LIMIT ?"
@@ -761,7 +772,11 @@ func (s *Store) SearchItems(workspaceID, query string) ([]ItemSearchResult, erro
args = append(args, workspaceID)
}
sqlQuery += " ORDER BY rank_score LIMIT 50"
if s.dialect.Driver() == DriverPostgres {
sqlQuery += " ORDER BY rank_score DESC LIMIT 50"
} else {
sqlQuery += " ORDER BY rank_score LIMIT 50"
}
rows, err := s.db.Query(s.q(sqlQuery), args...)
if err != nil {
+15
View File
@@ -332,6 +332,21 @@ func isAlpha(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '_'
}
// isValidFieldKey checks that a field name contains only safe characters
// (alphanumeric, underscore, hyphen). This prevents SQL injection when
// field keys from user input are interpolated into JSON path expressions.
func isValidFieldKey(key string) bool {
if key == "" {
return false
}
for _, c := range key {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
return false
}
}
return true
}
// q rebinds a query to the store's dialect (converts "?" to "$1", "$2", etc. for PostgreSQL).
func (s *Store) q(query string) string {
return s.dialect.Rebind(query)