Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .well-known/security.txt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Canonical: https://helpwave.de/.well-known/security.txt
Contact: mailto:security@helpwave.de
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/720952685A7162BDA45F27DBC62B9749E1C6B631
Expires: 2028-08-31T23:59:00Z
Preferred-Languages: en, de
8 changes: 8 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,14 @@

**helpwave tasks** is a modern, open-source task and ward-management platform tailored for healthcare - designed to bring clarity, efficiency and structure to hospitals, wards and clinical workflows.

> ⚠️ **Pre-release — not for productive use.** This project is still under active
> development and has **not been released yet**. It is **not ready for
> production or real patient data**, and no stability, security, or data-safety
> guarantees are made at this stage. We expect this to change over the coming
> month. Until then, use it for evaluation and development only.
>
> Found a security issue? Please report it privately — see [`SECURITY.md`](SECURITY.md).

## Quick Start

If you simply want to test the application without modifying code, use the production compose file. This pulls official images and runs them behind a reverse proxy.
Expand Down
36 changes: 36 additions & 0 deletions SECURITY.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
# Security Policy

> **Pre-release software.** `helpwave/tasks` is under active development and is
> **not yet released for productive use**. It has not completed a full security
> review, and no deployment should be treated as production-ready yet. This is
> expected to change over the coming month.

## Reporting a vulnerability

Please report security issues privately — do **not** open a public GitHub issue
or pull request for a suspected vulnerability.

Follow helpwave's central vulnerability disclosure policy:
<https://helpwave.de/.well-known/security.txt>

- **Contact:** security@helpwave.de
- **Encryption (PGP):** https://keys.openpgp.org/vks/v1/by-fingerprint/720952685A7162BDA45F27DBC62B9749E1C6B631
- **Preferred languages:** English, German

When reporting, please include:

- affected component and version/commit,
- a description of the issue and its impact,
- reproduction steps or a proof of concept,
- any suggested remediation.

## How reports are resolved

1. We acknowledge your report by email.
2. We triage and confirm the issue, and agree a coordinated disclosure timeline
with you.
3. We develop and validate a fix on a private branch, then merge and release it.
4. We credit reporters who wish to be acknowledged once a fix is available.

A machine-readable copy of these contact details is served from
[`.well-known/security.txt`](.well-known/security.txt).
27 changes: 27 additions & 0 deletions backend/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,8 +35,35 @@ INFLUXDB_URL=http://localhost:8086
INFLUXDB_TOKEN=tasks-token-secret
INFLUXDB_ORG=tasks
INFLUXDB_BUCKET=audit

# Optional hardening knobs
ADDITIONAL_ISSUERS= # extra trusted token issuers (comma-separated)
GRAPHQL_MAX_DEPTH=15 # reject documents deeper than this
GRAPHQL_MAX_ALIASES=50 # reject documents with more aliases than this
GRAPHQL_MAX_TOKENS=2000 # reject documents with more tokens than this
```

## Security model

Authentication and authorization are enforced server-side, deny-by-default:

- **Authentication.** Access tokens are verified with the realm JWKS
(signature, expiry, trusted issuer, and audience/`azp`). Tokens are read only
from the `Authorization: Bearer` header (HTTP and WebSocket
`connection_params`); the `access_token` cookie is honoured in development
only, and tokens are never read from the query string.
- **GraphQL is locked down.** Anonymous HTTP requests to `/graphql` are
rejected with `401` in production; the only thing an unauthenticated caller
may do (in development) is introspection. The GraphiQL IDE, GET queries, and
schema introspection are disabled outside development. A schema extension
denies every non-introspection field for an unauthenticated caller, including
fields wrapped in fragments. Subscriptions require a valid token at connect
time. Documents are bounded by depth/alias/token limits.
- **Authorization is location-scoped.** Every resolver restricts reads and
writes to the caller's accessible location subtree (rooted at
`user_root_locations`). Property definitions and saved views are attached to a
scaffold location and are only visible/editable inside that scope.

## Development Setup

1. **Create virtual environment**:
Expand Down
65 changes: 58 additions & 7 deletions backend/api/context.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,10 +5,11 @@

import strawberry
from auth import get_token_from_connection_params, get_user_payload, verify_token
from config import IS_DEV
from database.models.location import LocationNode, location_organizations
from database.models.user import User, user_root_locations
from database.session import get_db_session
from fastapi import Depends
from fastapi import Depends, HTTPException
from graphql import GraphQLError
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
Expand DownExpand Up@@ -168,11 +169,22 @@ async def get_user_from_connection_params(
try:
user_payload = verify_token(token)
except Exception as e:
logger.warning("WebSocket auth failed for token: %s", e)
logger.warning("WebSocket authentication rejected: %s", e)
return None
return await _resolve_user_from_payload(session, user_payload)


def _is_websocket(connection: HTTPConnection) -> bool:
return getattr(connection, "scope", {}).get("type") == "websocket"


def _is_graphql_http(connection: HTTPConnection) -> bool:
scope = getattr(connection, "scope", {})
if scope.get("type") == "websocket":
return False
return str(scope.get("path", "")).rstrip("/").endswith("/graphql")


async def get_context(
connection: HTTPConnection,
session=Depends(get_db_session),
Expand All@@ -185,6 +197,13 @@ async def get_context(
organizations = _organizations_from_payload(user_payload)
db_user = await _resolve_user_from_payload(session, user_payload)

if db_user is None and not IS_DEV and _is_graphql_http(connection):
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)

return Context(db=session, user=db_user, organizations=organizations)


Expand DownExpand Up@@ -250,13 +269,45 @@ async def _update_user_root_locations(

if not root_location_ids:
personal_org_title = f"{user.username}'s Organization"
result = await session.execute(
select(LocationNode).where(
LocationNode.title == personal_org_title,

existing_personal = await session.execute(
select(LocationNode)
.join(
user_root_locations,
LocationNode.id == user_root_locations.c.location_id,
)
.outerjoin(
location_organizations,
LocationNode.id == location_organizations.c.location_id,
)
.where(
user_root_locations.c.user_id == user.id,
LocationNode.parent_id.is_(None),
),
location_organizations.c.location_id.is_(None),
)
)
personal_location = result.scalars().first()
personal_location = existing_personal.scalars().first()

if not personal_location:
result = await session.execute(
select(LocationNode)
.outerjoin(
location_organizations,
LocationNode.id == location_organizations.c.location_id,
)
.outerjoin(
user_root_locations,
LocationNode.id == user_root_locations.c.location_id,
)
.where(
LocationNode.title == personal_org_title,
LocationNode.parent_id.is_(None),
location_organizations.c.location_id.is_(None),
(user_root_locations.c.user_id == user.id)
| (user_root_locations.c.user_id.is_(None)),
),
)
personal_location = result.scalars().first()

if not personal_location:
personal_location = LocationNode(
Expand Down
9 changes: 9 additions & 0 deletions backend/api/errors.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,9 +5,18 @@
"if you believe this is an error."
)

UNAUTHENTICATED_MESSAGE = "Not authenticated"


def raise_forbidden(message: str | None = None) -> None:
raise GraphQLError(
message or FORBIDDEN_MESSAGE,
extensions={"code": "FORBIDDEN"},
)


def raise_unauthenticated(message: str | None = None) -> None:
raise GraphQLError(
message or UNAUTHENTICATED_MESSAGE,
extensions={"code": "UNAUTHENTICATED"},
)
66 changes: 50 additions & 16 deletions backend/api/extensions.py
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,63 @@
from graphql import FieldNode, GraphQLError
from graphql import (
FieldNode,
FragmentSpreadNode,
GraphQLError,
InlineFragmentNode,
OperationDefinitionNode,
)
from strawberry.extensions import SchemaExtension


def _iter_top_level_fields(document, selection_set, fragments, seen_fragments):
if selection_set is None:
return
for selection in selection_set.selections:
if isinstance(selection, FieldNode):
yield selection
elif isinstance(selection, InlineFragmentNode):
yield from _iter_top_level_fields(
document, selection.selection_set, fragments, seen_fragments
)
elif isinstance(selection, FragmentSpreadNode):
name = selection.name.value
if name in seen_fragments:
continue
seen_fragments.add(name)
fragment = fragments.get(name)
if fragment is not None:
yield from _iter_top_level_fields(
document, fragment.selection_set, fragments, seen_fragments
)


class GlobalAuthExtension(SchemaExtension):
def on_execute(self):
execution_context = self.execution_context
user = execution_context.context.user
user = getattr(execution_context.context, "user", None)

if user:
if user is not None:
yield
return

document = execution_context.graphql_document
if document:
if document is not None:
fragments = {
definition.name.value: definition
for definition in document.definitions
if not isinstance(definition, OperationDefinitionNode)
and hasattr(definition, "name")
and definition.name is not None
}
for definition in document.definitions:
if definition.kind == "operation_definition":
for selection in definition.selection_set.selections:
if not isinstance(selection, FieldNode):
continue

if selection.name.value.startswith("__"):
continue

raise GraphQLError(
message="Not authenticated",
extensions={"code": "UNAUTHENTICATED"},
)
if not isinstance(definition, OperationDefinitionNode):
continue
for field in _iter_top_level_fields(
document, definition.selection_set, fragments, set()
):
if field.name.value.startswith("__"):
continue
raise GraphQLError(
message="Not authenticated",
extensions={"code": "UNAUTHENTICATED"},
)
yield
2 changes: 2 additions & 0 deletions backend/api/inputs.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -159,6 +159,7 @@ class CreatePropertyDefinitionInput:
description: str | None = None
options: list[str] | None = None
is_active: bool = True
location_id: strawberry.ID | None = None


@strawberry.input
Expand DownExpand Up@@ -210,6 +211,7 @@ class CreateSavedViewInput:
related_sort_definition: str = "{}"
related_parameters: str = "{}"
visibility: SavedViewVisibility = SavedViewVisibility.LINK_SHARED
location_id: strawberry.ID | None = None


@strawberry.input
Expand Down
Loading
Loading