Skip to content

Commit 7a55134

Browse files
committed
implement multiple security enhancements
1 parent b0d2595 commit 7a55134

41 files changed

Lines changed: 1540 additions & 273 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.well-known/security.txt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Canonical: https://helpwave.de/.well-known/security.txt
2+
Contact: mailto:security@helpwave.de
3+
Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/720952685A7162BDA45F27DBC62B9749E1C6B631
4+
Expires: 2028-08-31T23:59:00Z
5+
Preferred-Languages: en, de

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
**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.
77

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

1018
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.

‎SECURITY.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Security Policy
2+
3+
> **Pre-release software.** `helpwave/tasks` is under active development and is
4+
> **not yet released for productive use**. It has not completed a full security
5+
> review, and no deployment should be treated as production-ready yet. This is
6+
> expected to change over the coming month.
7+
8+
## Reporting a vulnerability
9+
10+
Please report security issues privately — do **not** open a public GitHub issue
11+
or pull request for a suspected vulnerability.
12+
13+
Follow helpwave's central vulnerability disclosure policy:
14+
<https://helpwave.de/.well-known/security.txt>
15+
16+
- **Contact:** security@helpwave.de
17+
- **Encryption (PGP):** https://keys.openpgp.org/vks/v1/by-fingerprint/720952685A7162BDA45F27DBC62B9749E1C6B631
18+
- **Preferred languages:** English, German
19+
20+
When reporting, please include:
21+
22+
- affected component and version/commit,
23+
- a description of the issue and its impact,
24+
- reproduction steps or a proof of concept,
25+
- any suggested remediation.
26+
27+
## How reports are resolved
28+
29+
1. We acknowledge your report by email.
30+
2. We triage and confirm the issue, and agree a coordinated disclosure timeline
31+
with you.
32+
3. We develop and validate a fix on a private branch, then merge and release it.
33+
4. We credit reporters who wish to be acknowledged once a fix is available.
34+
35+
A machine-readable copy of these contact details is served from
36+
[`.well-known/security.txt`](.well-known/security.txt).

‎backend/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,35 @@ INFLUXDB_URL=http://localhost:8086
3535
INFLUXDB_TOKEN=tasks-token-secret
3636
INFLUXDB_ORG=tasks
3737
INFLUXDB_BUCKET=audit
38+
39+
# Optional hardening knobs
40+
ADDITIONAL_ISSUERS= # extra trusted token issuers (comma-separated)
41+
GRAPHQL_MAX_DEPTH=15 # reject documents deeper than this
42+
GRAPHQL_MAX_ALIASES=50 # reject documents with more aliases than this
43+
GRAPHQL_MAX_TOKENS=2000 # reject documents with more tokens than this
3844
```
3945

46+
## Security model
47+
48+
Authentication and authorization are enforced server-side, deny-by-default:
49+
50+
- **Authentication.** Access tokens are verified with the realm JWKS
51+
(signature, expiry, trusted issuer, and audience/`azp`). Tokens are read only
52+
from the `Authorization: Bearer` header (HTTP and WebSocket
53+
`connection_params`); the `access_token` cookie is honoured in development
54+
only, and tokens are never read from the query string.
55+
- **GraphQL is locked down.** Anonymous HTTP requests to `/graphql` are
56+
rejected with `401` in production; the only thing an unauthenticated caller
57+
may do (in development) is introspection. The GraphiQL IDE, GET queries, and
58+
schema introspection are disabled outside development. A schema extension
59+
denies every non-introspection field for an unauthenticated caller, including
60+
fields wrapped in fragments. Subscriptions require a valid token at connect
61+
time. Documents are bounded by depth/alias/token limits.
62+
- **Authorization is location-scoped.** Every resolver restricts reads and
63+
writes to the caller's accessible location subtree (rooted at
64+
`user_root_locations`). Property definitions and saved views are attached to a
65+
scaffold location and are only visible/editable inside that scope.
66+
4067
## Development Setup
4168

4269
1. **Create virtual environment**:

‎backend/api/context.py‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import strawberry
77
from auth import get_token_from_connection_params, get_user_payload, verify_token
8+
from config import IS_DEV
89
from database.models.location import LocationNode, location_organizations
910
from database.models.user import User, user_root_locations
1011
from database.session import get_db_session
11-
from fastapi import Depends
12+
from fastapi import Depends, HTTPException
1213
from graphql import GraphQLError
1314
from sqlalchemy import delete, select
1415
from sqlalchemy.dialects.postgresql import insert
@@ -168,11 +169,22 @@ async def get_user_from_connection_params(
168169
try:
169170
user_payload = verify_token(token)
170171
except Exception as e:
171-
logger.warning("WebSocket auth failed for token: %s", e)
172+
logger.warning("WebSocket authentication rejected: %s", e)
172173
return None
173174
return await _resolve_user_from_payload(session, user_payload)
174175

175176

177+
def _is_websocket(connection: HTTPConnection) -> bool:
178+
return getattr(connection, "scope", {}).get("type") == "websocket"
179+
180+
181+
def _is_graphql_http(connection: HTTPConnection) -> bool:
182+
scope = getattr(connection, "scope", {})
183+
if scope.get("type") == "websocket":
184+
return False
185+
return str(scope.get("path", "")).rstrip("/").endswith("/graphql")
186+
187+
176188
async def get_context(
177189
connection: HTTPConnection,
178190
session=Depends(get_db_session),
@@ -185,6 +197,13 @@ async def get_context(
185197
organizations = _organizations_from_payload(user_payload)
186198
db_user = await _resolve_user_from_payload(session, user_payload)
187199

200+
if db_user is None and not IS_DEV and _is_graphql_http(connection):
201+
raise HTTPException(
202+
status_code=401,
203+
detail="Not authenticated",
204+
headers={"WWW-Authenticate": "Bearer"},
205+
)
206+
188207
return Context(db=session, user=db_user, organizations=organizations)
189208

190209

@@ -250,13 +269,45 @@ async def _update_user_root_locations(
250269

251270
if not root_location_ids:
252271
personal_org_title = f"{user.username}'s Organization"
253-
result = await session.execute(
254-
select(LocationNode).where(
255-
LocationNode.title == personal_org_title,
272+
273+
existing_personal = await session.execute(
274+
select(LocationNode)
275+
.join(
276+
user_root_locations,
277+
LocationNode.id == user_root_locations.c.location_id,
278+
)
279+
.outerjoin(
280+
location_organizations,
281+
LocationNode.id == location_organizations.c.location_id,
282+
)
283+
.where(
284+
user_root_locations.c.user_id == user.id,
256285
LocationNode.parent_id.is_(None),
257-
),
286+
location_organizations.c.location_id.is_(None),
287+
)
258288
)
259-
personal_location = result.scalars().first()
289+
personal_location = existing_personal.scalars().first()
290+
291+
if not personal_location:
292+
result = await session.execute(
293+
select(LocationNode)
294+
.outerjoin(
295+
location_organizations,
296+
LocationNode.id == location_organizations.c.location_id,
297+
)
298+
.outerjoin(
299+
user_root_locations,
300+
LocationNode.id == user_root_locations.c.location_id,
301+
)
302+
.where(
303+
LocationNode.title == personal_org_title,
304+
LocationNode.parent_id.is_(None),
305+
location_organizations.c.location_id.is_(None),
306+
(user_root_locations.c.user_id == user.id)
307+
| (user_root_locations.c.user_id.is_(None)),
308+
),
309+
)
310+
personal_location = result.scalars().first()
260311

261312
if not personal_location:
262313
personal_location = LocationNode(

‎backend/api/errors.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55
"if you believe this is an error."
66
)
77

8+
UNAUTHENTICATED_MESSAGE = "Not authenticated"
9+
810

911
def raise_forbidden(message: str | None = None) -> None:
1012
raise GraphQLError(
1113
message or FORBIDDEN_MESSAGE,
1214
extensions={"code": "FORBIDDEN"},
1315
)
16+
17+
18+
def raise_unauthenticated(message: str | None = None) -> None:
19+
raise GraphQLError(
20+
message or UNAUTHENTICATED_MESSAGE,
21+
extensions={"code": "UNAUTHENTICATED"},
22+
)

‎backend/api/extensions.py‎

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,63 @@
1-
from graphql import FieldNode, GraphQLError
1+
from graphql import (
2+
FieldNode,
3+
FragmentSpreadNode,
4+
GraphQLError,
5+
InlineFragmentNode,
6+
OperationDefinitionNode,
7+
)
28
from strawberry.extensions import SchemaExtension
39

410

11+
def _iter_top_level_fields(document, selection_set, fragments, seen_fragments):
12+
if selection_set is None:
13+
return
14+
for selection in selection_set.selections:
15+
if isinstance(selection, FieldNode):
16+
yield selection
17+
elif isinstance(selection, InlineFragmentNode):
18+
yield from _iter_top_level_fields(
19+
document, selection.selection_set, fragments, seen_fragments
20+
)
21+
elif isinstance(selection, FragmentSpreadNode):
22+
name = selection.name.value
23+
if name in seen_fragments:
24+
continue
25+
seen_fragments.add(name)
26+
fragment = fragments.get(name)
27+
if fragment is not None:
28+
yield from _iter_top_level_fields(
29+
document, fragment.selection_set, fragments, seen_fragments
30+
)
31+
32+
533
class GlobalAuthExtension(SchemaExtension):
634
def on_execute(self):
735
execution_context = self.execution_context
8-
user = execution_context.context.user
36+
user = getattr(execution_context.context, "user", None)
937

10-
if user:
38+
if user is not None:
1139
yield
1240
return
1341

1442
document = execution_context.graphql_document
15-
if document:
43+
if document is not None:
44+
fragments = {
45+
definition.name.value: definition
46+
for definition in document.definitions
47+
if not isinstance(definition, OperationDefinitionNode)
48+
and hasattr(definition, "name")
49+
and definition.name is not None
50+
}
1651
for definition in document.definitions:
17-
if definition.kind == "operation_definition":
18-
for selection in definition.selection_set.selections:
19-
if not isinstance(selection, FieldNode):
20-
continue
21-
22-
if selection.name.value.startswith("__"):
23-
continue
24-
25-
raise GraphQLError(
26-
message="Not authenticated",
27-
extensions={"code": "UNAUTHENTICATED"},
28-
)
52+
if not isinstance(definition, OperationDefinitionNode):
53+
continue
54+
for field in _iter_top_level_fields(
55+
document, definition.selection_set, fragments, set()
56+
):
57+
if field.name.value.startswith("__"):
58+
continue
59+
raise GraphQLError(
60+
message="Not authenticated",
61+
extensions={"code": "UNAUTHENTICATED"},
62+
)
2963
yield

‎backend/api/inputs.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class CreatePropertyDefinitionInput:
159159
description: str | None = None
160160
options: list[str] | None = None
161161
is_active: bool = True
162+
location_id: strawberry.ID | None = None
162163

163164

164165
@strawberry.input
@@ -210,6 +211,7 @@ class CreateSavedViewInput:
210211
related_sort_definition: str = "{}"
211212
related_parameters: str = "{}"
212213
visibility: SavedViewVisibility = SavedViewVisibility.LINK_SHARED
214+
location_id: strawberry.ID | None = None
213215

214216

215217
@strawberry.input

0 commit comments

Comments
 (0)