Skip to content

Security: suprbdev/gqlgate

Security

docs/security.md

Security

gqlgate has no authentication or authorization of its own. It forwards headers and lets the upstream decide. That makes the header policy the whole of its security surface.

The invariant: inbound claim headers are always stripped

pdbq's trusted-gateway auth mode (rls.auth.mode: headers) believes any X-Pdbq-Claim-* header it receives. Its own documentation says that mode belongs strictly behind a gateway that strips inbound claim headers.

gqlgate is that gateway. Every inbound header matching upstream.claim_prefix is dropped before the upstream request is built, unconditionally, with no configuration to disable it. If it were overridable, any client could send

X-Pdbq-Claim-Role: postgres

and assume a superuser role. The first stripped header per process is logged at warn level so a misconfigured client is visible without flooding the log.

This applies whatever the upstream's auth mode is: gqlgate cannot know whether the target trusts those headers, so it never forwards them.

Header forwarding

Inbound → upstream is a denylist. gqlgate is a transparent pipe; an allowlist would break every custom auth scheme, tracing header and tenant header a deployment has. Dropped:

  • Hop-by-hop headers per RFC 7230 (Connection, Keep-Alive, Proxy-*, TE, Trailer, Transfer-Encoding, Upgrade), plus anything named in the inbound Connection header.
  • Headers gqlgate sets itself: Content-Type, Content-Length, Content-Encoding, Accept, Accept-Encoding, Host.
  • Anything in upstream.strip_headers.
  • Anything matching upstream.claim_prefix, as above.

Everything else — including Authorization — is forwarded untouched.

Upstream → client is an allowlist. Only headers named in upstream.forward_response_headers are copied back. An unexpected upstream should not be able to set arbitrary headers on gqlgate's response. Set-Cookie is refused even when explicitly allowlisted: forwarding cookies from an upstream is a session-fixation vector.

Proxy provenance

gqlgate appends the client IP to X-Forwarded-For and sets X-Forwarded-Proto and X-Forwarded-Host. pdbq's rls.auth.trusted_proxies check inspects the direct peer, which is gqlgate, so configure it with gqlgate's address:

# pdbq.yaml
rls:
  auth:
    mode: headers
    trusted_proxies: ["10.0.0.0/8"]  # the network gqlgate runs on

Claim injection

upstream.inject_claims lets gqlgate supply claims itself, for deployments where it terminates authentication:

upstream:
  inject_claims:
    Role: "app_user"
    UserId: '{{.Header "X-Authenticated-User"}}'

Injected claims are unauthenticated trust: whoever can reach gqlgate gets whatever role is named. Accordingly:

  • It is off by default.
  • Templates may only read inbound request headers. There is no arbitrary logic.
  • Startup refuses a configuration that injects claims toward a non-loopback, non-private upstream unless upstream.allow_insecure_claims is set. Injected claims must not cross a public network.
  • A loud warning is logged at boot whenever injection is enabled.
  • Injection runs after stripping, so an injected claim can never be overridden by a client-supplied header of the same name.

Only enable this when the upstream is on a private network and gqlgate is the only thing that can reach it.

Error disclosure

By default, upstream error messages, their path, and their extensions (including PostgreSQL SQLSTATEs and constraint names) are passed through. That is useful in development and for well-behaved API clients, but it does reveal schema details.

For a public deployment, redact:

errors:
  include_extensions: false
  include_path: false

Consider also setting pdbq's errors.detail: strict, which stops constraint and column names leaving the database layer at all.

Error locations are always dropped: they refer to the document gqlgate generated, which the client never saw and cannot act on.

The OpenAPI document

/openapi.json describes every route being served, including column names and enum values. That is the same information gqlgate routes prints and that any client of the API can infer, so it is served by default — but it does hand an unauthenticated caller a map of the schema.

For a deployment where the API's shape should not be public:

server:
  openapi:
    enabled: false          # or keep it and restrict access at the proxy
    allow_any_origin: false # stops a docs UI on another origin loading it

allow_any_origin relaxes CORS for the document alone. The data endpoints always obey server.cors_origins, so turning it on does not expose data — only the description.

The bundled Scalar UI is a development convenience. It is not authenticated, and docker compose up publishes it. Do not run that stack as-is in production.

Deployment checklist

  • The upstream is not reachable directly from clients — only through gqlgate. Otherwise the claim stripping is bypassed.
  • rls.enabled: true on pdbq, with roles and policies configured.
  • upstream.inject_claims is empty unless you deliberately want gqlgate to assert identity, and the upstream is on a private network.
  • errors.include_extensions is false for a public API.
  • routes.allow_unfiltered_bulk is false (the default), so DELETE /users with an empty filter cannot empty a table.
  • server.cors_origins lists explicit origins rather than * if browsers call the API with credentials.
  • gqlgate routes reviewed, so you know exactly what is exposed. Use routes.ignore to withhold fields that should not be public.
  • server.openapi.enabled is a deliberate choice, and the bundled Scalar UI container is not running in production.

There aren't any published security advisories