Skip to content

Harden GraphQL auth/authz (deny-by-default) and proxy - #358

Closed
felixevers wants to merge 3 commits into
mainfrom
claude/unauthenticated-graphql-access-9z3xnx
Closed

Harden GraphQL auth/authz (deny-by-default) and proxy#358
felixevers wants to merge 3 commits into
mainfrom
claude/unauthenticated-graphql-access-9z3xnx

Conversation

@felixevers

Copy link
Copy Markdown
Member

Summary

Locks down the GraphQL API and proxy end-to-end, following a deny-by-default philosophy at every layer: nothing is reachable unless the caller is authenticated and explicitly authorized for the data in scope.

The trigger was unauthenticated GraphQL access, but the review widened to the whole auth/authz surface (code scanners + a full resolver authorization audit). Backend tests: 131 passing (the only non-passing ones require a live Redis, which CI provides). ruff, bandit, and semgrep are clean; pip-audit reports no known vulnerabilities.

Authentication (backend/auth.py)

  • Verify access tokens against the realm JWKS with PyJWT — signature, expiry, a trusted-issuer allowlist, and audience/azp. Replaces python-jose (which pulled a vulnerable transitive ecdsa) with PyJWT[crypto].
  • Accept tokens only from the Authorization: Bearer header and WebSocket connection_params; never from the query string. The access_token cookie is honoured in development only.

GraphQL boundary — deny-by-default

  • Anonymous HTTP /graphql401 in production.
  • WebSocket connections are rejected unless they present a valid token (on_ws_connect).
  • GraphiQL IDE, GET queries, and schema introspection are off outside development; documents are bounded by depth/alias/token limits.
  • The global auth extension now denies every non-introspection field for an unauthenticated caller, resolving fragment spreads and inline fragments — closing the original bypass where wrapping a query in a fragment slipped past the gate.

Authorization — location/scaffold-scoped

  • Property definitions and saved views now attach to a scaffold location (nullable location_id + migration) and are authorized against the caller's accessible subtree; legacy/global rows are read-only / owner-only. Property values are validated against in-scope definitions.
  • Audit logs: authorized by case access and case_id constrained to a safe charset — closes the IDOR and the Flux-injection vector; limit/offset clamped.
  • Subscriptions: scoped to the caller's accessible roots instead of trusting a client-supplied (or omitted) filter, which previously acted as a global firehose.
  • Cross-scope reads/writes fixed: user directory, location tree traversal (parent/children/patients), task assignment (team + assignees), client-controlled location handles, rootless location creation, and task-preset edit/delete (creator-only).
  • A user's personal root location is bound to the user, not just the username.

Proxy (proxy/nginx.conf, nix/packages/proxy.nix)

  • /graphql restricted to POST and WebSocket upgrades; /callback route dropped; security headers, server_tokens off, and a request-rate limit added.

Tests & docs

  • New unit tests for token verification and the GraphQL auth gate (incl. fragment-bypass cases), and integration tests for the authorization scoping.
  • Refreshed backend/README.md, docs/how-it-works.md, and the exported schema.

Notes for reviewers

  • New GraphQL input/type fields (locationId) are optional/additive, so existing frontend operations remain valid; regenerate web/api/gql/generated.ts when convenient to surface them.
  • The 16 backend mutation tests that touch Redis pass under CI (which runs a Redis service) but fail in an environment without one.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QLL4RTec1rS16qsDEPBHCa


Generated by Claude Code

Enforce authentication and authorization deny-by-default at every layer.
Authentication (backend/auth.py):
- Verify access tokens with the realm JWKS via PyJWT (signature, expiry,
trusted issuer allowlist, and audience/azp), replacing python-jose (which
pulled a vulnerable ecdsa) with PyJWT[crypto].
- Accept tokens only from the Authorization bearer header and WebSocket
connection_params; never from the query string. The cookie is dev-only.
GraphQL boundary (main.py, api/context.py, api/router.py, api/extensions.py):
- Reject anonymous HTTP /graphql with 401 in production.
- Reject WebSocket connections that do not present a valid token.
- Disable the GraphiQL IDE, GET queries, and schema introspection outside
development; add depth/alias/token limits.
- Rewrite the global auth extension to deny every non-introspection field for
an unauthenticated caller, resolving fragment spreads and inline fragments so
a query cannot be smuggled past the gate.
Authorization (location/scaffold-scoped):
- Attach property definitions and saved views to a scaffold location
(nullable location_id + migration) and authorize both against the caller's
accessible subtree; legacy/global rows are read-only / owner-only.
- Validate property values against definitions in scope.
- Authorize audit_logs by case access and constrain case_id to a safe charset,
closing the IDOR and the Flux-injection vector; clamp limit/offset.
- Scope subscriptions to the caller's accessible roots instead of trusting a
client-supplied (or omitted) filter.
- Scope the user directory, location tree traversal (parent/children/patients),
task assignment (team and assignees), client-controlled location handles, and
forbid creating rootless location nodes.
- Restrict task-preset edit/delete to the creator.
- Bind a user's personal root location to the user, not just the username.
Proxy (proxy/nginx.conf, nix/packages/proxy.nix):
- Restrict /graphql to POST and WebSocket upgrades, drop the /callback route,
add security headers, server_tokens off, and a request-rate limit.
Add unit and integration tests for token verification, the GraphQL auth gate,
and the authorization scoping; refresh docs and the exported schema.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLL4RTec1rS16qsDEPBHCa
The pinned Strawberry invokes a factory-callable extension as
`ext(execution_context=None)`; the no-arg `lambda:` factories raised
`TypeError`, 500-ing every GraphQL request in the built image (caught by the
E2E proxy stack, not the resolver unit tests which bypass `schema.execute`).
Pass the stateless request-shape limiters as instances (used directly via the
`isinstance` path) and keep the auth gate as a class. Add a schema-execution
smoke test that runs a query through the configured schema so this class of
extension-wiring bug fails a unit test on the pinned version.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLL4RTec1rS16qsDEPBHCa
- Add SECURITY.md and .well-known/security.txt pointing to helpwave's
vulnerability disclosure policy (security@helpwave.de) with the report and
resolution process.
- State prominently in the README and SECURITY.md that the project is
pre-release and not for productive use yet, changing over the coming month.
- Remove the explanatory code comments and docstrings added in this branch to
match the repository's comment-free style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLL4RTec1rS16qsDEPBHCa
@felixevers
felixevers deleted the claude/unauthenticated-graphql-access-9z3xnx branch September 2, 2026 06:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@felixevers@claude