Files
xorm/schemas/quote_injection_test.go
zeekayandHanzo Dev 00e6fc6266 schemas: escape the delimiter inside a quoted identifier
quoteWordTo wrapped an identifier in the dialect delimiter but wrote the body
verbatim, so a delimiter inside the identifier closed the quote the writer
opened and the remainder ran as SQL. Desc/Asc route caller strings straight
here through Quoter.QuoteTo:

  Desc("name`,(select token from secret)--")
    -> ORDER BY `name`,(select token from secret)--` DESC

two quoted identifiers plus a bare subquery, not one identifier. The word looks
quoted, so callers treat Desc/Asc as identifier-safe; it was not.

Double the closing delimiter — the standard SQL identifier escape — and force a
quoted form for any word that carries one, even a dialect that would otherwise
emit it bare. A name that smuggled a delimiter becomes one identifier that does
not exist, so it fails closed:

  -> ORDER BY `name``,(select token from secret)--` DESC   (no such column)

Same for the " and ] delimiters. Legitimate names hold no delimiter and are
unchanged, so the existing quoter cases and the round-trip through Trim still
hold. Tests assert the emitted quoted string per dialect.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 10:08:53 -07:00

64 lines
2.1 KiB
Go

// Copyright 2024 The Xorm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package schemas
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// quoteWordTo wraps an identifier in the dialect delimiter but wrote the body
// verbatim, so a delimiter INSIDE the identifier closed the quote the writer
// opened and the remainder executed. Desc("name`,(subquery)--") became
//
// `name`,(subquery)--`
//
// two quoted identifiers plus bare SQL, not one identifier. The escape is to
// DOUBLE the closing delimiter (standard SQL identifier escaping); this asserts
// the emitted string, so it fails if the escape is dropped.
func TestQuoteWordCannotBreakOutOfItsDelimiter(t *testing.T) {
cases := []struct {
name string
quoter Quoter
in string
want string
}{
{
name: "backtick (mysql/sqlite)",
quoter: Quoter{'`', '`', AlwaysReserve},
in: "name`,(select/**/token/**/from/**/secret)--",
want: "`name``,(select/**/token/**/from/**/secret)--`",
},
{
name: "double-quote (postgres/standard)",
quoter: Quoter{'"', '"', AlwaysReserve},
in: `name",(select/**/token/**/from/**/secret)--`,
want: `"name"",(select/**/token/**/from/**/secret)--"`,
},
{
name: "bracket (mssql) — only the closing ] is doubled",
quoter: Quoter{'[', ']', AlwaysReserve},
in: `name],(select/**/token/**/from/**/secret)--`,
want: `[name]],(select/**/token/**/from/**/secret)--]`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.quoter.Quote(c.in)
t.Logf("Quote(%q) = %q", c.in, got)
assert.Equal(t, c.want, got)
// Structural invariant: strip the outer delimiter pair; every closing
// delimiter left in the body must be doubled, so its count is even.
// A lone one would terminate the quote and start bare SQL.
inner := got[1 : len(got)-1]
assert.Equal(t, 0, strings.Count(inner, string(c.quoter.Suffix))%2,
"interior closing delimiters must all be escaped (doubled)")
})
}
}