Escape special characters in FTS5 search queriesUnverified
450d849 parent: dc0dcf0 modified
internal/db/article.go +18 -1 | @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "database/sql" |
| 6 | 6 | "strings" |
| 7 | 7 | "time" |
| 8 | + "unicode" | |
| 8 | 9 | ) |
| 9 | 10 | |
| 10 | 11 | type Article struct { |
| @@ -295,11 +296,27 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T | ||
| 295 | 296 | return count, err |
| 296 | 297 | } |
| 297 | 298 | |
| 299 | +func escapeFTS5(query string) string { | |
| 300 | + var b strings.Builder | |
| 301 | + b.Grow(len(query)) | |
| 302 | + for _, r := range query { | |
| 303 | + if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { | |
| 304 | + b.WriteRune(r) | |
| 305 | + } | |
| 306 | + } | |
| 307 | + return b.String() | |
| 308 | +} | |
| 309 | + | |
| 298 | 310 | func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { |
| 299 | 311 | if strings.TrimSpace(query) == "" { |
| 300 | 312 | return nil, nil |
| 301 | 313 | } |
| 302 | 314 | |
| 315 | + safeQuery := escapeFTS5(query) | |
| 316 | + if strings.TrimSpace(safeQuery) == "" { | |
| 317 | + return nil, nil | |
| 318 | + } | |
| 319 | + | |
| 303 | 320 | rows, err := db.QueryContext(ctx, ` |
| 304 | 321 | SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, |
| 305 | 322 | a.published, a.updated, a.fetched_at, |
| @@ -312,7 +329,7 @@ func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, | ||
| 312 | 329 | WHERE articles_fts MATCH ? |
| 313 | 330 | ORDER BY ft.rank |
| 314 | 331 | LIMIT ? OFFSET ? |
| 315 | - `, userDID, userDID, query, limit, offset) | |
| 332 | + `, userDID, userDID, safeQuery, limit, offset) | |
| 316 | 333 | if err != nil { |
| 317 | 334 | return nil, err |
| 318 | 335 | } |
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | "strings" | 6 | "strings" |
| 7 | "time" | 7 | "time" |
| 8 | + "unicode" | ||
| 8 | ) | 9 | ) |
| 9 | 10 | ||
| 10 | type Article struct { | 11 | type Article struct { |
| @@ -295,11 +296,27 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T | |||
| 295 | return count, err | 296 | return count, err |
| 296 | } | 297 | } |
| 297 | 298 | ||
| 299 | +func escapeFTS5(query string) string { | ||
| 300 | + var b strings.Builder | ||
| 301 | + b.Grow(len(query)) | ||
| 302 | + for _, r := range query { | ||
| 303 | + if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsSpace(r) { | ||
| 304 | + b.WriteRune(r) | ||
| 305 | + } | ||
| 306 | + } | ||
| 307 | + return b.String() | ||
| 308 | +} | ||
| 309 | + | ||
| 298 | func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { | 310 | func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { |
| 299 | if strings.TrimSpace(query) == "" { | 311 | if strings.TrimSpace(query) == "" { |
| 300 | return nil, nil | 312 | return nil, nil |
| 301 | } | 313 | } |
| 302 | 314 | ||
| 315 | + safeQuery := escapeFTS5(query) | ||
| 316 | + if strings.TrimSpace(safeQuery) == "" { | ||
| 317 | + return nil, nil | ||
| 318 | + } | ||
| 319 | + | ||
| 303 | rows, err := db.QueryContext(ctx, ` | 320 | rows, err := db.QueryContext(ctx, ` |
| 304 | SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, | 321 | SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, |
| 305 | a.published, a.updated, a.fetched_at, | 322 | a.published, a.updated, a.fetched_at, |
| @@ -312,7 +329,7 @@ func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, | |||
| 312 | WHERE articles_fts MATCH ? | 329 | WHERE articles_fts MATCH ? |
| 313 | ORDER BY ft.rank | 330 | ORDER BY ft.rank |
| 314 | LIMIT ? OFFSET ? | 331 | LIMIT ? OFFSET ? |
| 315 | - `, userDID, userDID, query, limit, offset) | 332 | + `, userDID, userDID, safeQuery, limit, offset) |
| 316 | if err != nil { | 333 | if err != nil { |
| 317 | return nil, err | 334 | return nil, err |
| 318 | } | 335 | } |
modified
internal/db/article_test.go +19 -0 | @@ -324,3 +324,22 @@ func TestSearchArticles_EmptyQuery(t *testing.T) { | ||
| 324 | 324 | assert.NilError(t, err) |
| 325 | 325 | assert.Equal(t, len(results), 0) |
| 326 | 326 | } |
| 327 | + | |
| 328 | +func TestSearchArticles_SpecialCharactersNoError(t *testing.T) { | |
| 329 | + ctx := context.Background() | |
| 330 | + db := setupTestDB(t) | |
| 331 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 332 | + | |
| 333 | + _, err := db.SearchArticles(ctx, userDID, "test.example.com/path?q=1&b=2", 10, 0) | |
| 334 | + assert.NilError(t, err) | |
| 335 | +} | |
| 336 | + | |
| 337 | +func TestSearchArticles_OnlySpecialCharacters(t *testing.T) { | |
| 338 | + ctx := context.Background() | |
| 339 | + db := setupTestDB(t) | |
| 340 | + userDID, _ := seedSearchData(t, ctx, db) | |
| 341 | + | |
| 342 | + results, err := db.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0) | |
| 343 | + assert.NilError(t, err) | |
| 344 | + assert.Equal(t, len(results), 0) | |
| 345 | +} | |
| @@ -324,3 +324,22 @@ func TestSearchArticles_EmptyQuery(t *testing.T) { | |||
| 324 | assert.NilError(t, err) | 324 | assert.NilError(t, err) |
| 325 | assert.Equal(t, len(results), 0) | 325 | assert.Equal(t, len(results), 0) |
| 326 | } | 326 | } |
| 327 | + | ||
| 328 | +func TestSearchArticles_SpecialCharactersNoError(t *testing.T) { | ||
| 329 | + ctx := context.Background() | ||
| 330 | + db := setupTestDB(t) | ||
| 331 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 332 | + | ||
| 333 | + _, err := db.SearchArticles(ctx, userDID, "test.example.com/path?q=1&b=2", 10, 0) | ||
| 334 | + assert.NilError(t, err) | ||
| 335 | +} | ||
| 336 | + | ||
| 337 | +func TestSearchArticles_OnlySpecialCharacters(t *testing.T) { | ||
| 338 | + ctx := context.Background() | ||
| 339 | + db := setupTestDB(t) | ||
| 340 | + userDID, _ := seedSearchData(t, ctx, db) | ||
| 341 | + | ||
| 342 | + results, err := db.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0) | ||
| 343 | + assert.NilError(t, err) | ||
| 344 | + assert.Equal(t, len(results), 0) | ||
| 345 | +} | ||