Skip to content
Merged
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: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ VITE_SIGNALING_SERVER_URL=wss://signaling.gdsjam.com
VITE_SIGNALING_SERVER_TOKEN=your-token-here

# TURN Server Configuration (for WebRTC NAT traversal)
# Password for TURN server authentication (required for file sync)
# Optional fallback only. Preferred path is server-issued ephemeral TURN credentials.
VITE_TURN_PASSWORD=your-turn-password-here

# File Server Configuration
VITE_FILE_SERVER_URL=https://signaling.gdsjam.com
VITE_FILE_SERVER_TOKEN=your-token-here
# Browser clients now request short-lived scoped tokens from /api/auth/token.
# Do not expose long-lived file-server auth tokens in Vite env variables.
4 changes: 2 additions & 2 deletions .env.production
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ VITE_FPS_UPDATE_INTERVAL=500
# VITE_SIGNALING_SERVER_TOKEN=<set-in-github-secrets>

# TURN Server Configuration (for WebRTC NAT traversal)
# Optional fallback only. Preferred path is server-issued ephemeral TURN credentials.
# VITE_TURN_PASSWORD=<set-in-github-secrets>

# File Server Configuration (for file upload/download)
# These should be set in GitHub Secrets for production deployment
# VITE_FILE_SERVER_URL=https://signaling.gdsjam.com
# VITE_FILE_SERVER_TOKEN=<set-in-github-secrets>

# Browser clients now use short-lived scoped tokens from /api/auth/token.
2 changes: 0 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ jobs:
VITE_TURN_PASSWORD: ${{ secrets.VITE_TURN_PASSWORD }}
# File server configuration (required for file upload/download)
VITE_FILE_SERVER_URL: ${{ secrets.VITE_FILE_SERVER_URL }}
VITE_FILE_SERVER_TOKEN: ${{ secrets.VITE_FILE_SERVER_TOKEN }}
# Debug mode (optional - defaults to .env.production value if not set)
# To enable debug in production, add VITE_DEBUG secret with value "true"
VITE_DEBUG: ${{ secrets.VITE_DEBUG }}
Expand All @@ -69,4 +68,3 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

124 changes: 124 additions & 0 deletions DevLog/DevLog-006-00-Viewer-Stability-and-Refactor-Fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# DevLog-006-00: Viewer Stability, API Token, and TURN Hardening

**Date**: 2026-02-15
**Status**: Complete
**Scope**: Consolidated execution log for DevLog-006 series:
- 006-00 Viewer stability and refactor fixes
- 006-01 Short-lived API token hardening
- 006-02 Ephemeral TURN credentials hardening

## Goals

1. Fix event-listener lifecycle leaks in viewer/renderer code paths.
2. Restore `pnpm check` pass status by removing known check blockers.
3. Replace long-lived client-exposed API token usage with short-lived scoped tokens.
4. Replace static TURN password runtime dependency with ephemeral TURN credentials.
5. Keep all changes behavior-preserving, migration-safe, and validated.

## Plan

- [x] Patch `PixiRenderer` layer-visibility listener to use a stable handler reference.
- [x] Patch `ViewerCanvas` custom resize listener to clean up correctly on unmount.
- [x] Patch `MeasurementOverlay` constructor/state to remove unused `app` member.
- [x] Add shared server auth module for short-lived scoped API tokens.
- [x] Add token issuance endpoint `POST /api/auth/token`.
- [x] Migrate file/python client calls to ephemeral token flow.
- [x] Add ephemeral TURN credential endpoint `GET /api/turn-credentials`.
- [x] Integrate TURN credential fetch in `YjsProvider` with static fallback.
- [x] Update docs/env/workflow notes for new security model.
- [x] Validate all slices with `pnpm check`, tests, and server syntax checks.

## Progress Log

### 2026-02-15 13:10

- Created DevLog and locked execution scope to targeted stability fixes.
- Next: apply code patches for listener cleanup and check failure.

### 2026-02-15 13:18

- Completed viewer stability code patches:
- `src/lib/renderer/PixiRenderer.ts`: stable handler reference for add/remove listener symmetry.
- `src/components/viewer/ViewerCanvas.svelte`: removed inline custom resize listener and added cleanup.
- `src/lib/renderer/overlays/MeasurementOverlay.ts`: removed unused `app` member and simplified constructor.
- Next: run validation (`pnpm check`, `pnpm test --run`) and log results.

### 2026-02-15 13:24

- Stability validation results:
- `pnpm check`: pass (`svelte-check found 0 errors and 0 warnings`).
- `pnpm test --run`: pass (6 files, 94 tests).
- Next: security hardening follow-up.

### 2026-02-15 13:34

- Started API token hardening with shared scope design:
- `files:read`
- `files:write`
- `python:execute`
- Next: patch server auth and token issuing endpoint.

### 2026-02-15 13:52

- Implemented API token hardening:
- Added `server/auth.js` for short-lived token signing/verification and scoped middleware.
- Added `POST /api/auth/token` in `server/server.js` with per-IP rate limit.
- Switched `server/fileStorage.js` and `server/pythonExecutor.js` to scoped auth middleware.
- Added `src/lib/api/authTokenClient.ts` with in-memory token cache.
- Replaced direct `VITE_FILE_SERVER_TOKEN` usage in `FileTransfer`, `SessionManager`, and `PythonExecutor`.
- Updated env/workflow/docs to remove browser long-lived file token usage.
- Next: run full validation and finalize status.

### 2026-02-15 14:16

- API token hardening validation:
- `pnpm check`: pass (0 errors, 0 warnings)
- `pnpm test --run`: pass (6 files, 94 tests)
- `node --check` on modified server files: pass
- Patch complete. Long-lived browser-exposed file token usage removed from runtime code paths.

### 2026-02-15 14:24

- Started TURN hardening after two pushed commits:
- `ed7340b` stability fixes
- `01af1b7` short-lived API token hardening
- Next: implement server endpoint and client integration.

### 2026-02-15 14:41

- Added TURN hardening implementation:
- `server/turnCredentials.js`: `GET /api/turn-credentials` endpoint for ephemeral TURN credentials.
- `server/auth.js`: added `turn:read` scope.
- `src/lib/api/turnCredentialsClient.ts`: client fetch + in-memory cache.
- `src/lib/collaboration/YjsProvider.ts`: fetches ephemeral TURN creds first, falls back to static `VITE_TURN_PASSWORD`.
- `src/lib/collaboration/SessionManager.ts`: awaits async `yjsProvider.connect(...)`.
- Updated environment/docs for TURN REST secret setup.
- Next: run validation and finalize status.

### 2026-02-15 14:46

- TURN hardening validation:
- `pnpm check`: pass (0 errors, 0 warnings)
- `pnpm test --run`: pass (6 files, 94 tests)
- `node --check` for modified server files: pass
- TURN hardening slice complete.

### 2026-02-15 15:28

- Added automated test coverage for the new security and TURN logic:
- `tests/api/authTokenClient.test.ts`
- `tests/api/turnCredentialsClient.test.ts`
- `tests/api/pythonExecutor.auth.test.ts`
- `tests/collaboration/FileTransfer.auth.test.ts`
- `tests/collaboration/YjsProvider.turn.test.ts`
- `tests/server/auth.test.ts`
- `tests/server/turnCredentials.test.ts`
- Test run result:
- `pnpm test --run`: pass (13 files, 109 tests)

## Sensitive Data Review

- Reviewed consolidated DevLog content for secrets/tokens/credentials.
- No raw secret values, real tokens, passwords, private keys, or credential strings are present.
- Mentions of environment variables (`AUTH_TOKEN`, `API_TOKEN_SECRET`, `TURN_SHARED_SECRET`, `VITE_TURN_PASSWORD`) are generic configuration identifiers only.
- Commit hashes included are public git identifiers, not sensitive data.
14 changes: 14 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ PORT=4444
# This token must be included in the WebSocket URL: ws://server:4444?token=YOUR_TOKEN
AUTH_TOKEN=your-secure-token-here

# Short-lived API token signing secret (optional but recommended).
# If not set, AUTH_TOKEN is used as fallback secret.
API_TOKEN_SECRET=your-api-token-signing-secret
# API token TTL in seconds (default: 300)
# API_TOKEN_TTL_SECONDS=300

# TURN REST credential configuration (for ephemeral TURN credentials)
# coturn should be configured with `use-auth-secret` and same static-auth-secret value.
TURN_SHARED_SECRET=your-turn-shared-secret
# TURN_REALM=signaling.gdsjam.com
# TURN_TTL_SECONDS=600
# TURN_USERNAME_PREFIX=gdsjam
# TURN_URLS=turn:signaling.gdsjam.com:3478,turn:signaling.gdsjam.com:3478?transport=tcp,turns:signaling.gdsjam.com:5349?transport=tcp

# Allowed Origins (comma-separated)
# Only connections from these origins will be accepted
# Default: https://gdsjam.com,http://localhost:5173,http://localhost:4173
Expand Down
23 changes: 21 additions & 2 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ PORT=8080 pnpm start

The server implements three layers of security:

### 1. Token Authentication
### 1. Token Authentication (WebSocket + API)

Set an `AUTH_TOKEN` in your `.env` file:

Expand All @@ -53,7 +53,19 @@ Clients must include the token in the WebSocket URL:
ws://your-server:4444?token=your-generated-token-here
```

**Note**: Since GDSJam is a client-side app, the token will be visible in the client code. This provides basic protection against casual abuse but is not fully secure. Anyone inspecting the client code can extract the token.
For REST APIs (`/api/files`, `/api/execute`), browser clients should request short-lived scoped tokens:

```bash
POST /api/auth/token
{ "scopes": ["files:read", "files:write"] }
```

These tokens are:
- scoped (`files:read`, `files:write`, `python:execute`, `turn:read`)
- short-lived (default 5 minutes)
- bound to client IP

Long-lived `AUTH_TOKEN` is still accepted for operational backward compatibility.

### 2. Origin Checking

Expand All @@ -72,6 +84,7 @@ ALLOWED_ORIGINS=https://gdsjam.com,https://yourdomain.com
Protects against DoS attacks:
- **Default**: 10 connections per IP per minute
- Configurable in `server.js` (RATE_LIMIT_WINDOW, RATE_LIMIT_MAX_CONNECTIONS)
- API token issuance is also rate-limited per IP (`API_TOKEN_RATE_LIMIT_*`)

### Security Limitations

Expand Down Expand Up @@ -182,6 +195,12 @@ Key settings:
- Domain: signaling.gdsjam.com
- SSL certificate: /etc/letsencrypt/live/signaling.gdsjam.com/

For ephemeral TURN credentials (recommended), configure coturn with TURN REST auth:
- `use-auth-secret`
- `static-auth-secret=<TURN_SHARED_SECRET>`

The server then exposes `GET /api/turn-credentials` and clients no longer need a static TURN password in frontend env.

To modify configuration:
```bash
sudo nano /etc/turnserver.conf
Expand Down
154 changes: 154 additions & 0 deletions server/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
const crypto = require("crypto");

const AUTH_TOKEN = process.env.AUTH_TOKEN;
const API_TOKEN_SECRET = process.env.API_TOKEN_SECRET || AUTH_TOKEN || "";
const API_TOKEN_TTL_SECONDS = parseInt(process.env.API_TOKEN_TTL_SECONDS || "300", 10); // 5 min

const ALLOWED_SCOPES = new Set(["files:read", "files:write", "python:execute", "turn:read"]);

function toBase64Url(input) {
return Buffer.from(input)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

function fromBase64Url(input) {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4));
return Buffer.from(normalized + pad, "base64").toString("utf8");
}

function normalizeIp(req) {
// Respect common proxy headers when present.
const forwarded = req.headers["x-forwarded-for"];
const candidate = Array.isArray(forwarded)
? forwarded[0]
: typeof forwarded === "string"
? forwarded.split(",")[0]
: req.ip || req.socket?.remoteAddress || "";
return String(candidate).trim();
}

function parseBearerToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) return null;
return authHeader.substring(7);
}

function getValidatedScopes(scopes) {
if (!Array.isArray(scopes) || scopes.length === 0) return [];
const unique = [...new Set(scopes.map((s) => String(s)))];
for (const scope of unique) {
if (!ALLOWED_SCOPES.has(scope)) {
throw new Error(`Invalid scope: ${scope}`);
}
}
return unique;
}

function signApiToken(payload) {
if (!API_TOKEN_SECRET) {
throw new Error("API_TOKEN_SECRET (or AUTH_TOKEN) is required to sign API tokens");
}
const body = toBase64Url(JSON.stringify(payload));
const signature = crypto.createHmac("sha256", API_TOKEN_SECRET).update(body).digest("base64url");
return `${body}.${signature}`;
}

function verifyApiToken(token) {
if (!API_TOKEN_SECRET) return { valid: false, reason: "Server auth secret not configured" };
const parts = token.split(".");
if (parts.length !== 2) return { valid: false, reason: "Invalid token format" };

const [body, signature] = parts;
const expectedSignature = crypto
.createHmac("sha256", API_TOKEN_SECRET)
.update(body)
.digest("base64url");
if (signature !== expectedSignature) return { valid: false, reason: "Invalid token signature" };

let payload;
try {
payload = JSON.parse(fromBase64Url(body));
} catch {
return { valid: false, reason: "Invalid token payload" };
}

const now = Date.now();
if (typeof payload.exp !== "number" || payload.exp <= now) {
return { valid: false, reason: "Token expired" };
}
if (!Array.isArray(payload.scopes)) {
return { valid: false, reason: "Invalid token scopes" };
}
return { valid: true, payload };
}

function hasRequiredScopes(tokenScopes, requiredScopes) {
if (!requiredScopes || requiredScopes.length === 0) return true;
if (!Array.isArray(tokenScopes)) return false;
return requiredScopes.every((scope) => tokenScopes.includes(scope));
}

function authenticateRequest(requiredScopes = []) {
return (req, res, next) => {
if (!AUTH_TOKEN) {
return next(); // auth disabled
}

const token = parseBearerToken(req);
if (!token) {
return res.status(401).json({ error: "Missing or invalid authorization header" });
}

// Backward compatibility: allow long-lived AUTH_TOKEN for operators/tools.
if (token === AUTH_TOKEN) {
return next();
}

const verification = verifyApiToken(token);
if (!verification.valid) {
return res.status(401).json({ error: verification.reason || "Invalid token" });
}

const requestIp = normalizeIp(req);
if (verification.payload.ip && verification.payload.ip !== requestIp) {
return res.status(401).json({ error: "Token IP mismatch" });
}
if (!hasRequiredScopes(verification.payload.scopes, requiredScopes)) {
return res.status(403).json({ error: "Insufficient token scope" });
}

req.apiToken = verification.payload;
next();
};
}

function issueShortLivedToken(req, scopes = []) {
const validatedScopes = getValidatedScopes(scopes);
const now = Date.now();
const expiresAt = now + API_TOKEN_TTL_SECONDS * 1000;
const payload = {
iat: now,
exp: expiresAt,
ip: normalizeIp(req),
scopes: validatedScopes,
};
return {
token: signApiToken(payload),
expiresAt,
expiresIn: API_TOKEN_TTL_SECONDS,
scopes: validatedScopes,
};
}

module.exports = {
ALLOWED_SCOPES,
AUTH_TOKEN,
API_TOKEN_TTL_SECONDS,
authenticateRequest,
getValidatedScopes,
issueShortLivedToken,
};
Loading