Skip to content

v1.7.0

Latest

Choose a tag to compare

@mstgnz mstgnz released this 10 Sep 21:48

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_at and deleted_at with the literal text invalid_identifier while raising no error.
  • SQL text enters only through Expr and the Raw methods, 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.
  • Where never treats a value as a column. Use Col or WhereColumn for a column comparison.
  • Select, OrderBy and GroupBy take names, not expressions. Select("COUNT(*) as total") is now an error. Use SelectCount("*", "total") or SelectRaw.
  • Raw replaces the whole statement rather than appending to the WHERE clause. Use WhereRaw for that.
  • Prepare and Sql return empty on error instead of a partial statement, and no longer reset the builder.
  • A SELECT requires a table. Use Raw for a statement with no FROM.

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.