Skip to content
Draft
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
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is the canonical source of truth for SAPOT's known security-relevant config
| Hardcoded JWT secret fallback | `server/app/db_operations/token.py` (`SECRET_KEY`) | The default value has been removed; `JWT_SECRET_KEY` is now required, and the app raises `RuntimeError` at import time if unset. **Rotate to a newly generated secret** (`openssl rand -hex 32`) — the old hardcoded value must be considered compromised since it was committed to source. |
| CORS wildcard + credentials | `server/app/main.py` | `allow_origins=["*"]` replaced with an explicit allowlist read from `CORS_ALLOWED_ORIGINS` (comma-separated). The app raises `RuntimeError` at import time if unset. |
| Testing router in production | `server/app/main.py`, `server/app/api/testing.py` | The router is imported and mounted only in `development` or `staging`. A router-wide dependency also returns 404 outside those environments if the router is mis-mounted. Every state-changing route requires the `X-QA-Token` shared secret. A production-process regression test exercises every testing path. |
| WebSocket JWTs exposed in request URLs (issue #225) | Server `/ws/` and `/gps/ws/*`; mobile and admin clients | WebSocket URLs no longer contain JWTs. Clients offer exactly `sapot.jwt` and the access token through `Sec-WebSocket-Protocol`; the server selects `sapot.jwt`. Query-token compatibility was removed, and non-development clients require `wss://`. |

## Required environment variables (new)

Expand Down
38 changes: 30 additions & 8 deletions admin-frontend/sapot-admin/lib/ws/Websocketmanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ export async function getToken(): Promise<string> {
return data.token;
}

export function normalizeWebSocketDomain(
configuredDomain: string | undefined,
fallbackHost: string,
isDevelopment = process.env.NODE_ENV === "development",
): string {
const value = configuredDomain?.trim() || `wss://${fallbackHost}`;
const withProtocol = value.includes("://") ? value : `wss://${value}`;
const normalized = withProtocol
.replace(/^http:\/\//, "ws://")
.replace(/^https:\/\//, "wss://")
.replace(/\/+$/, "");

if (!isDevelopment && normalized.startsWith("ws://")) {
throw new Error("Plaintext WebSocket URLs are only allowed in development");
}

if (!normalized.startsWith("ws://") && !normalized.startsWith("wss://")) {
throw new Error("WebSocket domain must use ws:// or wss://");
}

return normalized;
}

/* =========================
SEND HELPERS
========================= */
Expand Down Expand Up @@ -221,14 +244,13 @@ export async function connectWebSocket(userId: string) {

try {
const token = await getToken();
const raw = process.env.NEXT_PUBLIC_WEBSOCKET_DOMAIN;
const wsDomain = (raw || `wss://${window.location.host}`)
.replace(/^http:\/\//, "ws://")
.replace(/^https:\/\//, "wss://");

const url = `${wsDomain}/ws/?token=${token}`;
const wsDomain = normalizeWebSocketDomain(
process.env.NEXT_PUBLIC_WEBSOCKET_DOMAIN,
window.location.host,
);
const url = `${wsDomain}/ws/`;

socket = new WebSocket(url);
socket = new WebSocket(url, ["sapot.jwt", token]);

socket.onopen = () => {
console.log("[WS] Connected");
Expand Down Expand Up @@ -262,7 +284,7 @@ export async function connectWebSocket(userId: string) {
await handleSeen(parsed.data as SeenPayload);
break;
default:
console.warn("[WS] Unknown message type:", (parsed as any).type);
console.warn("[WS] Unknown message type");
}
};

Expand Down
8 changes: 4 additions & 4 deletions docs/api/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,10 @@ Profile pictures are served at `/static/profile_pictures/<filename>` directly by

## WebSocket Authentication

WebSocket endpoints authenticate via a `token` query parameter (browsers cannot set custom headers on WS upgrades):
WebSocket endpoints authenticate through the standard subprotocol offer because browser clients cannot set an `Authorization` header on the upgrade request:

```
wss://<host>/ws/?token=<access_token>
```javascript
const socket = new WebSocket("wss://<host>/ws/", ["sapot.jwt", accessToken]);
```

The server closes the connection with code 1008 (policy violation) if the token is invalid.
The offer must contain exactly `sapot.jwt` followed by the access token. The server selects `sapot.jwt` after validating the token. Missing, malformed, expired, or extra protocols close with code 1008 (policy violation). Any `token` query parameter is rejected, even when the subprotocol credentials are valid.
24 changes: 15 additions & 9 deletions docs/api/gps.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

Machine-readable spec: [`openapi/gps.yaml`](openapi/gps.yaml) (generated from the live FastAPI app — REST routes only; WebSocket routes are not representable in OpenAPI and are documented in prose below). Note: neither REST route declares a `response_model`, so the generated YAML's `200` response schema is empty (`{}`) — the JSON examples below are the only documented shape for these responses.

GPS endpoints stream and query user location data (router in `server/app/api/gps.py`, prefix `/gps`). All GPS endpoints require JWT Bearer auth (via `token` query param for WebSocket routes).
GPS endpoints stream and query user location data (router in `server/app/api/gps.py`, prefix `/gps`). REST endpoints use JWT Bearer auth. WebSocket routes use the `sapot.jwt` subprotocol contract.

## Endpoints at a glance

| Method | Path | Auth | Summary |
|---|---|---|---|
| WS | `/gps/ws/{user_id}` | `token` query param (JWT) | Stream live GPS coordinates from a user's device; server persists and fans out to monitoring rescuers. |
| WS | `/gps/ws/monitor/rescuers/{rescuer_id}` | `token` query param (JWT, rescuer role) | Live feed of every user's GPS updates, for rescuers. |
| WS | `/gps/ws/{user_id}` | `Sec-WebSocket-Protocol: sapot.jwt, <JWT>` | Stream live GPS coordinates from a user's device; server persists and fans out to monitoring rescuers. |
| WS | `/gps/ws/monitor/rescuers/{rescuer_id}` | `Sec-WebSocket-Protocol: sapot.jwt, <JWT>`; rescuer role | Live feed of every user's GPS updates, for rescuers. |
| GET | `/gps/latest` | JWT Bearer (rescuer role) | Most recent location for every user who has sent at least one ping. |
| GET | `/gps/history/{user_id}` | JWT Bearer (rescuer role) | Location history for a specific user, most recent first. |

Expand All @@ -19,10 +19,13 @@ GPS endpoints stream and query user location data (router in `server/app/api/gps

Stream live GPS coordinates from a user's device to the server. The server persists each ping and fans out to all monitoring rescuers in real time.

**Auth:** `token` query parameter (JWT)
**Auth:** ordered WebSocket subprotocol offer `sapot.jwt`, then the access token

```
wss://<host>/gps/ws/<user_uuid>?token=<access_token>
```javascript
const socket = new WebSocket("wss://<host>/gps/ws/<user_uuid>", [
"sapot.jwt",
accessToken,
]);
```

**Validation:**
Expand Down Expand Up @@ -62,10 +65,13 @@ users based on this field.

Open a live feed of all users' GPS updates. Rescuers only.

**Auth:** `token` query parameter (JWT)
**Auth:** ordered WebSocket subprotocol offer `sapot.jwt`, then the access token

```
wss://<host>/gps/ws/monitor/rescuers/<rescuer_uuid>?token=<access_token>
```javascript
const socket = new WebSocket(
"wss://<host>/gps/ws/monitor/rescuers/<rescuer_uuid>",
["sapot.jwt", accessToken],
);
```

**Validation:**
Expand Down
17 changes: 10 additions & 7 deletions docs/api/messaging-and-websocket.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Machine-readable spec: [`openapi/messaging-and-websocket.yaml`](openapi/messagin

| Method | Path | Auth | Summary |
|---|---|---|---|
| WS | `/ws/` | `token` query param (JWT); optional `target_id` | Real-time hub: chat relay, WebRTC signalling, presence, public chat. |
| WS | `/ws/` | `Sec-WebSocket-Protocol: sapot.jwt, <JWT>`; optional `target_id` | Real-time hub: chat relay, WebRTC signalling, presence, public chat. |
| GET | `/public-chat` | JWT Bearer | Paginated public chat history. Query params: `limit` (default 100), `before` (created_at cursor, epoch ms). |

## Overview
Expand All @@ -23,15 +23,18 @@ The server does **not** read message content — it relays encrypted blobs. The

## WebSocket /ws/

**Auth:** `token` query parameter (JWT)
**Auth:** ordered WebSocket subprotocol offer `sapot.jwt`, then the access token

```
wss://<host>/ws/?token=<access_token>
wss://<host>/ws/?token=<access_token>&target_id=<uuid>
```javascript
const socket = new WebSocket("wss://<host>/ws/", ["sapot.jwt", accessToken]);
const targetedSocket = new WebSocket("wss://<host>/ws/?target_id=<uuid>", [
"sapot.jwt",
accessToken,
]);
```

**On connect:**
1. Token is validated; connection is rejected (code 1008) if invalid.
1. The subprotocol offer and token are validated; the server selects `sapot.jwt`. Invalid or ambiguous offers close with code 1008.
2. Queued messages for this user are drained and delivered.
3. A `status-update` broadcast (`online`) is sent to all connected users.

Expand All @@ -45,7 +48,7 @@ sequenceDiagram
participant Server as /ws/
participant Others as Other connected clients

Client->>Server: connect wss://host/ws/?token=<jwt>
Client->>Server: connect wss://host/ws/<br/>protocols: sapot.jwt, JWT
alt token invalid
Server-->>Client: close (code 1008)
else token valid
Expand Down
42 changes: 0 additions & 42 deletions docs/api/openapi/messaging-and-websocket.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,48 +41,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/ws/:
get:
tags:
- websockets
- peer connection
summary: Testing Area
operationId: testing_area_ws__get
parameters:
- name: target_id
in: query
required: true
schema:
type: string
format: uuid
title: Target Id
- name: my_id
in: query
required: true
schema:
type: string
format: uuid
title: My Id
- name: token
in: query
required: true
schema:
type: string
title: Token
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'404':
description: Not Found
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
HTTPValidationError:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/networking-lan-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ sequenceDiagram

## Server connectivity

The SAPOT server is a fixed node on the LAN (static IP or DHCP reservation). Nginx listens on port 443 (TLS). All mobile REST and WebSocket connections go to this server via `https://<server-LAN-IP>/`.
The SAPOT server is a fixed node on the LAN (static IP or DHCP reservation). Nginx listens on port 443 (TLS). Mobile REST connections use `https://<server-LAN-IP>/`, and WebSocket connections use `wss://<server-LAN-IP>/`.

---

Expand Down
6 changes: 6 additions & 0 deletions docs/architecture/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ The server uses a dual-token system (access + refresh):

JWT signing uses `PyJWT`. Token expiry constants are in `db_operations/token.py`.

### WebSocket authentication

WebSocket clients send no JWT in the request URL. They offer exactly two subprotocol values in order: `sapot.jwt` and the access token. The server validates the token, then accepts the connection with `sapot.jwt` selected.

The server closes the handshake with code 1008 when the protocols are missing, malformed, expired, or ambiguous. It also rejects every `token` query parameter, including requests that also provide valid subprotocol credentials. Production and preview clients use `wss://`; plaintext `ws://` is limited to explicit local development.

---

## Password hashing
Expand Down
4 changes: 2 additions & 2 deletions docs/deployment/environment-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Set in EAS project secrets or a local `.env` file (not committed).
|---|---|---|
| `APP_VARIANT` | Build variant: `development`, `preview`, or unset for production | EAS build |
| `SERVER_CA` | Base64-encoded **private CA** PEM, materialized into `server_ca.pem` at prebuild by `app.config.ts`'s `withServerCa` | EAS cloud builds only |
| `EXPO_PUBLIC_DEV_HOST` | Dev-only server hostname/IP used by `config/runtime.ts` to build the API/WS/tile-server URLs (`https://<DEV_HOST>`, `wss://<DEV_HOST>`) | `__DEV__` builds only |
| `EXPO_PUBLIC_DEV_HOST` | Dev-only server hostname/IP used by `config/runtime.ts` to build the API/WS/tile-server URLs. WSS is the default; an explicit plaintext WebSocket origin is accepted only in `__DEV__`. | `__DEV__` builds only |
| `EXPO_PUBLIC_SERVER_VERIFY_KEY` | Public key used to verify the server's identity/signature (`config/runtime.ts` → `getServerVerifyKey()`) | All builds, if server signing is enabled |
| `EXPO_PUBLIC_ENABLED_LOG_MODULES` | Comma-separated log scopes; unset = all | Development only |
| `EXPO_PUBLIC_LOG_TO_FILE` | Set to `1` to force file logging in dev (always on in non-dev builds) | Development only |
Expand Down Expand Up @@ -115,7 +115,7 @@ Set in EAS project secrets or a local `.env` file (not committed).
|---|---|---|
| `API_DOMAIN` | SAPOT server base URL, read server-side only (`api/fetch.ts`, `api/login.ts`, `app/api/**/route.ts`, `actions/auth.ts`) — **not** prefixed `NEXT_PUBLIC_`, so it never reaches the client bundle | Yes |
| `NEXT_PUBLIC_MAP_STYLE` | MapLibre tile style URL (`ui/components/MapLibre.tsx`) | Yes |
| `NEXT_PUBLIC_WEBSOCKET_DOMAIN` | WebSocket server domain (`lib/ws/Websocketmanager.ts`, non-null asserted — required) | Yes |
| `NEXT_PUBLIC_WEBSOCKET_DOMAIN` | Browser-facing WebSocket origin used by `lib/ws/Websocketmanager.ts`. Production must use `wss://`; local development may explicitly use `ws://` or `http://`. Keep this separate from internal `API_DOMAIN` Docker hostnames. | Yes |
| `NODE_ENV` | Toggles the `secure` flag on auth cookies (production vs dev) | Set by the Node runtime; not usually hand-set |

Set in `.env.local` (not committed) or the host service manager. `admin-frontend/sapot-admin/.env.example`
Expand Down
9 changes: 6 additions & 3 deletions docs/features/gps/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ useEffect(() => {
const { status } = await Location.requestForegroundPermissionsAsync()
if (status !== 'granted') { setPermissionDenied(true); return }

ws = new WebSocket(`ws://${serverHost}/gps/ws/${userId}?token=${jwt}`)
ws = new WebSocket(`wss://${serverHost}/gps/ws/${userId}`, ['sapot.jwt', jwt])
ws.onopen = () => {
locationSub = await Location.watchPositionAsync(
{ accuracy: Location.Accuracy.Balanced, timeInterval: 5000 },
Expand Down Expand Up @@ -112,8 +112,10 @@ monitorWs.onmessage = (e) => {

| Path | Role | Direction | Description |
|-----------------------------------|------------|----------------|---------------------------------------|
| `/gps/ws/{user_id}?token=JWT` | Any user | client → server| Receive location frames; save + relay |
| `/gps/ws/monitor/rescuers/{id}?token=JWT` | Rescuer | server → client | Push live location frames |
| `/gps/ws/{user_id}` | Any user | client → server| Receive location frames; save + relay |
| `/gps/ws/monitor/rescuers/{id}` | Rescuer | server → client | Push live location frames |

Both endpoints require the ordered WebSocket subprotocols `sapot.jwt` and the access token. The server selects `sapot.jwt` after authentication. A query token or an ambiguous protocol offer closes with code 1008.

### Connection Manager

Expand Down Expand Up @@ -188,6 +190,7 @@ sequenceDiagram

The GPS WebSocket is managed entirely within `useGpsStreaming` and `gps.py`. It:
- Uses a separate WebSocket URL path (`/gps/ws/*` vs `/ws`).
- Keeps credentials out of the URL by sending the access token as the second `Sec-WebSocket-Protocol` value.
- Has its own reconnection logic independent of `ConnectionService`.
- Does not share state with the messaging or signalling layers.
- Can remain connected even if the messaging WebSocket is disconnected.
Expand Down
5 changes: 3 additions & 2 deletions docs/features/gps/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ GPS location sharing uses a dedicated, server-mediated WebSocket layer independe
### FR-GP-01 — Location Streaming (Any User)

- Any authenticated user may stream their GPS location to the server.
- The mobile app opens a WebSocket connection to `WS /gps/ws/{user_id}?token=<JWT>`.
- The mobile app opens `WS /gps/ws/{user_id}` and offers `sapot.jwt` followed by the access token as WebSocket subprotocols.
- The app sends location frames at a configurable interval (default 5 seconds):

```json
Expand Down Expand Up @@ -58,7 +58,7 @@ GPS location sharing uses a dedicated, server-mediated WebSocket layer independe

### FR-GP-05 — Rescuer Monitor WebSocket

- Rescuers open a separate WebSocket `WS /gps/ws/monitor/rescuers/{rescuer_id}?token=<JWT>` to receive live location broadcasts.
- Rescuers open a separate `WS /gps/ws/monitor/rescuers/{rescuer_id}` connection with the same `sapot.jwt` subprotocol contract to receive live location broadcasts.
- The server pushes each new `UserLocation` frame to all connected rescuer monitors immediately after saving.
- Monitor connection is independent of the main messaging WebSocket.

Expand Down Expand Up @@ -93,6 +93,7 @@ GPS location sharing uses a dedicated, server-mediated WebSocket layer independe
| NFR-GP-02 | Server must handle 50 concurrent streaming users without degradation |
| NFR-GP-03 | GPS WebSocket must reconnect automatically after network interruption |
| NFR-GP-04 | Location data must not be transmitted over unencrypted connections |
| NFR-GP-05 | WebSocket request URLs must not contain access tokens |

---

Expand Down
3 changes: 3 additions & 0 deletions docs/features/gps/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
| Scenario | Expected result |
|----------|-----------------|
| User connects to `WS /gps/ws/{user_id}` with valid JWT | Connection accepted; user added to `streaming_connections` |
| User offers `sapot.jwt` followed by a valid JWT | Connection accepted with `sapot.jwt` selected; URL contains no credential |
| User sends a query token or malformed subprotocol list | Connection closes with code 1008 |
| User sends `{ "lat": 14.5995, "lng": 120.9842 }` | Row inserted in `user_locations` with correct user_id and timestamp |
| User sends malformed frame (missing `lng`) | Connection receives error frame; no DB write |
| User disconnects | Removed from `streaming_connections`; no error |
Expand Down Expand Up @@ -69,6 +71,7 @@
| Hook unmounts | WebSocket `close()` called; `locationSub.remove()` called |
| Permission denied on mount | `permissionDenied` state set to `true`; WebSocket never opened |
| WebSocket closes unexpectedly | Reconnect attempted after 3 s; exponential back-off applied |
| WebSocket reconnects | Constructor receives `["sapot.jwt", currentToken]`; URL still contains no token |
| Component unmounts before reconnect fires | Reconnect timer cleared; no further connection attempts |
| GPS update fires while WS is not yet open | Frame queued or skipped; no crash |

Expand Down
23 changes: 23 additions & 0 deletions mobile-app/sapot-mobile-app/config/__tests__/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ describe("getWsUrl", () => {
jest.resetModules();
});

it("allows an explicit plaintext websocket URL in development", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__DEV__ = true;
const { normalizeWebSocketUrl } = require("../runtime");

expect(normalizeWebSocketUrl("http://127.0.0.1:8000/")).toBe(
"ws://127.0.0.1:8000"
);
});

it("rejects plaintext websocket URLs outside development", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__DEV__ = false;
const { normalizeWebSocketUrl } = require("../runtime");

expect(() => normalizeWebSocketUrl("ws://server.sapot.lan")).toThrow(
"Plaintext WebSocket URLs are only allowed in development"
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).__DEV__ = true;
});

it("should return development websocket URL when in development mode", () => {
const { getWsUrl } = require("../runtime");
const result = getWsUrl();
Expand Down
Loading
Loading