Repository navigation
Security release. Upgrade from 1.6 and earlier.
Versions up to and including 1.6 carried a working SQL injection in Where.
A string value containing a dot was treated as a column name instead of being bound. The sanitiser it was then handed to returned its input untouched whenever the input also contained CASE, OVER, PARTITION BY, ROW_NUMBER, RANK or DENSE_RANK. Put together, an attacker needed to add a dot and the word OVER to their input:
// 1.6
gb.Table("app_user").Select("id", "email").
Where("email", "=", "a.b'; DROP TABLE auction; -- OVER").Prepare()
// SELECT id, email FROM app_user WHERE email = a.b'; DROP TABLE auction; -- OVER
// params: []The same code path also corrupted ordinary data. Where("email", "=", "user@example.com") produced WHERE email = userexample.com, unbound and with the @ stripped, so every email lookup written with this library was both wrong and open.
Fourteen further entry points interpolated their arguments with no checking at all, including OrderBy, GroupBy, Having, Join, WhereColumn, the column names of Create and Update, and the comparison operator itself.
If you cannot upgrade today, nothing in 1.6 makes Where safe for a value a user controls.
What changed
Query construction is rewritten. The statement is now held as a typed tree and compiled in one pass, and three rules hold structurally rather than by inspection:
- A value is always bound as a parameter, whatever it contains. There is no branch that looks at a value's text to decide what it is.
- A name is parsed, validated against a character allowlist, and quoted for the dialect. The old keyword blocklist is gone. It was wrong in both directions: it missed anything not on it, and it replaced
updated_atanddeleted_atwith the literal textinvalid_identifierwhile raising no error. - SQL text enters only through
Exprand theRawmethods, where the caller is visibly taking responsibility for the fragment.
Dialects actually work now
1.6 was PostgreSQL with a placeholder prefix. OFFSET x LIMIT y is a syntax error on MySQL, SQL Server and Oracle; Sql() never substituted anything on MySQL or SQLite; there was no identifier quoting, so a column called order broke everywhere.
Each dialect now has its own implementation covering pagination, quoting, upsert, RETURNING (OUTPUT on SQL Server), row locking, JSON containment, date extraction and NULLS ordering. Where a database has no equivalent, Build returns ErrUnsupported instead of emitting SQL that will not parse. MySQL and SQL Server get an emulated NULLS ordering, since neither accepts NULLS FIRST.
RegisterDialect lets you add a database this package does not ship with, without changing the package.
Breaking changes
The security fixes change the output of existing calls. Read this before upgrading.
- Identifiers are quoted, so every generated statement differs from 1.6. Tests comparing against golden SQL strings need updating.
The security fixes change the output of existing calls. Read this before upgrading.
- Identifiers are quoted, so every generated statement differs from 1.6. Tests comparing against golden SQL strings need updating.
Wherenever treats a value as a column. UseColorWhereColumnfor a column comparison.Select,OrderByandGroupBytake names, not expressions.Select("COUNT(*) as total")is now an error. UseSelectCount("*", "total")orSelectRaw.Rawreplaces the whole statement rather than appending to theWHEREclause. UseWhereRawfor that.PrepareandSqlreturn empty on error instead of a partial statement, and no longer reset the builder.- A
SELECTrequires a table. UseRawfor a statement with noFROM.
Full list in CHANGELOG.md.
Also new
Build() (string, []any, error) as the method to execute with. Nested condition groups through WhereGroup. Multiple and recursive CTEs, which 1.6 silently dropped: a second With used to discard the first. Aggregate helpers, INTERSECT and EXCEPT, upserts, JoinSub, FromSub, SelectSub, keyset-friendly Offset, typed sentinel errors for errors.Is, and ParseIdentifier exported so you can validate a name before it reaches the builder.
Union no longer renumbers placeholders with string replacement, which could not tell a placeholder from the same characters inside a literal and did nothing at all on ? dialects. Subqueries compile into the parent's parameter list, so numbering is correct by construction.
Performance
Roughly six times faster with a third of the allocations, on the same benchmarks as 1.6 (Apple M2 Max):
| 1.6 | 1.7 | |
|---|---|---|
| Select | 1951 ns, 30 allocs | 298 ns, 6 allocs |
| Insert | 2297 ns, 30 allocs | 402 ns, 9 allocs |
| Update | 2557 ns, 40 allocs | 449 ns, 9 allocs |
| Delete | 1836 ns, 28 allocs | 286 ns, 6 allocs |
That is with quoting and validation added, which 1.6 did not do properly. Identifier parsing allocates nothing.
Testing
96.4% coverage, run with -race, plus a dedicated injection suite across all five dialects and three fuzz targets. The central one asserts that the generated SQL text does not depend on the values at all, which is the check that would have caught the 1.6 defect immediately.
Still zero dependencies.
Reporting a vulnerability: SECURITY.md.