39948-vm/backend/docs/modules/middleware.md
2026-07-03 16:11:24 +02:00

843 lines
29 KiB
Markdown

# Backend Middleware Module Documentation
## Overview
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
**Files:**
| File | Lines | Purpose |
|------|-------|---------|
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
---
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────┐
│ Incoming Request │
└──────────────────────────────────┬───────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Express Middleware Stack │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 1. helmet() - Security headers │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 2. cors() - Cross-origin resource sharing │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 3. requestLogger - Pino HTTP logging │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 4. Rate Limiters (route-specific) │ │
│ │ • downloadLimiter → /api/file/download, /api/file/presign │ │
│ │ • uploadLimiter → /api/file/upload │ │
│ │ • searchLimiter → /api/search │ │
│ │ • authLimiter → /api/auth/signin │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 5. bodyParser.json() - JSON body parsing (after file routes) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 6. runtimeContextMiddleware - Environment context │ │
│ │ Reads: X-Runtime-Environment, X-Runtime-Project-Slug │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 7. JWT Authentication (route-specific) │ │
│ │ passport.authenticate('jwt', { session: false }) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 8. Runtime Public Middleware (route-specific) │ │
│ │ • blockNonPublicRuntimeListEndpoints │ │
│ │ • sanitizePublicRuntimeListResponse │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 9. checkCrudPermissions / checkPermissions (route-specific) │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 10. Route Handler │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 11. Error Handler (commonErrorHandler) │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
---
## File Details
### 1. rateLimiter.js (268 lines)
In-memory rate limiting middleware with configurable windows and limits.
#### Storage Architecture
```javascript
// In-memory store (Map)
const rateLimitStore = new Map();
// Entry structure
{
count: number, // Request count in window
expiresAt: number, // Window expiration timestamp
resetTime: string // ISO timestamp for headers
}
// Automatic cleanup every 5 minutes
setInterval(() => {
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.expiresAt <= now) {
rateLimitStore.delete(key);
}
}
}, 5 * 60 * 1000);
```
#### Factory: createRateLimiter(options)
Creates a configurable rate limiter middleware.
**Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
| `max` | number | `100` | Maximum requests per window |
| `message` | string | `'Too many requests...'` | Error message on limit |
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
**Returns:** Express middleware function
**Response Headers:**
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 2024-01-01T00:15:00.000Z
Retry-After: 300 (only when limit exceeded)
```
**Rate Limit Exceeded Response (429):**
```json
{
"error": "Too Many Requests",
"message": "Too many requests. Please try again later.",
"retryAfter": 300
}
```
#### Factory: createAuthenticatedRateLimiter(options)
Creates rate limiter that uses IP + User ID as key.
```javascript
const createAuthenticatedRateLimiter = (options = {}) => {
return createRateLimiter({
...options,
keyGenerator: (req) => {
const userId = req.currentUser?.id || 'anonymous';
const ip = req.ip || 'unknown';
return `${ip}:${userId}`;
},
});
};
```
#### Pre-configured Limiters
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|---------|------------|--------|-----|-------------|----------|
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
#### Route Mapping
```javascript
// index.js
app.use('/api/file/download', downloadLimiter);
app.use('/api/file/presign', downloadLimiter);
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file/upload-sessions', uploadLimiter);
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
// routes/auth.js
router.post('/signin/local', signinLimiter, handler);
router.post('/send-password-reset-email', passwordResetLimiter, handler);
```
#### Development Mode
Rate limiting is skipped for localhost in development:
```javascript
if (
config.server.env === 'development' &&
(req.ip === '127.0.0.1' || req.ip === '::1')
) {
return next(); // Skip rate limiting
}
```
---
### 2. check-permissions.ts (194 lines)
Role-based access control (RBAC) middleware. Permission decisions are delegated
to `src/services/access-policy.ts` so role/custom permission resolution and
Public-user hardening stay centralized.
#### Public Role Caching
```javascript
let publicRoleCache = null;
// Fetched on module load (startup)
async function fetchAndCachePublicRole() {
publicRoleCache = await RolesDBApi.findBy({ name: 'Public' });
}
// Called immediately when module is imported
fetchAndCachePublicRole();
```
#### Function: checkPermissions(permission)
Creates middleware that checks if user has specific permission.
**Permission Check Flow:**
```
1. AccessPolicy.hasPermission(user, permission)
├── Public users are always denied admin API permissions
└── Internal users use app_role.permissions + custom_permissions
2. Public role fallback
└── Unauthenticated/no-role requests use cached Public role, but Public role permissions are ignored
3. Role lacks permission → 403 Forbidden
```
Self-access bypass is not part of `checkPermissions`. It is explicitly limited
to `GET`, `PUT`, and `PATCH` on the authenticated user's own `/api/users/:id`
route in `checkCrudPermissions`.
**Usage:**
```javascript
const { checkPermissions } = require('./middlewares/check-permissions');
// Check specific permission
router.get('/admin', checkPermissions('ADMIN_ACCESS'), handler);
// Check entity permission
router.get('/users', checkPermissions('READ_USERS'), handler);
```
**Error Response (403):**
```json
{
"message": "Forbidden"
}
```
#### Function: checkCrudPermissions(name)
Creates middleware that maps HTTP method to CRUD permission.
**Method Mapping:**
| HTTP Method | Permission Prefix |
|-------------|-------------------|
| `POST` | `CREATE_` |
| `GET` | `READ_` |
| `PUT` | `UPDATE_` |
| `PATCH` | `UPDATE_` |
| `DELETE` | `DELETE_` |
**Permission Name Format:** `{METHOD}_{ENTITY}`
Examples:
- `GET /api/users``READ_USERS`
- `POST /api/projects``CREATE_PROJECTS`
- `DELETE /api/assets/123``DELETE_ASSETS`
Routes can set `req.permissionNameOverride` before `checkCrudPermissions` when
the HTTP verb does not describe the domain operation. The middleware uses the
override as the exact permission name and otherwise falls back to
`{METHOD}_{ENTITY}`. For example, environment-level resets for project runtime
settings use `DELETE` to remove an override row, but the user-facing operation
is "use inherited defaults", so those routes require `UPDATE_PAGE_ELEMENTS`
rather than `DELETE_PAGE_ELEMENTS`.
**Usage:**
```javascript
const { checkCrudPermissions } = require('./middlewares/check-permissions');
// In router factory
router.get('/', checkCrudPermissions('users'), listHandler);
router.post('/', checkCrudPermissions('users'), createHandler);
router.delete('/:id', checkCrudPermissions('users'), deleteHandler);
// For reset/update semantics implemented as DELETE
router.use((req, _res, next) => {
if (req.method === 'DELETE' && req.path.startsWith('/project/')) {
req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS';
}
next();
});
router.use(checkCrudPermissions('page_elements'));
```
#### Runtime Public Read Bypass
Certain entities allow public read access in production runtime:
```javascript
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
'GLOBAL_TRANSITION_DEFAULTS',
'PROJECT_TRANSITION_SETTINGS',
]);
// Bypass permission check for public runtime reads
const isRuntimePublicRead =
req.isRuntimePublicRequest === true &&
req.method === 'GET' &&
RUNTIME_PUBLIC_READ_ENTITIES.has(name.toUpperCase());
if (isRuntimePublicRead) {
return next(); // Skip permission check
}
```
**⚠️ Middleware Ordering Requirement:**
For public read bypass to work, the middleware that sets `req.isRuntimePublicRequest = true` **MUST run before** `checkCrudPermissions`. Common mistake:
```javascript
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
router.use(checkCrudPermissions('entity'));
router.get('/', allowPublicRead, handler); // Too late!
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
router.use(allowPublicRead);
router.use(checkCrudPermissions('entity'));
router.get('/', handler);
```
When using `router.use()`, middleware is applied to ALL routes before route-specific middleware runs.
---
### 3. runtime-context.ts (34 lines)
Middleware that extracts runtime environment context from request headers.
#### Function: runtimeContextMiddleware
Reads environment and project slug from headers for route-based access.
**Headers:**
| Header | Values | Description |
|--------|--------|-------------|
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
| `X-Runtime-Project-Slug` | string | Project identifier |
**Context Object:**
```javascript
req.runtimeContext = {
mode: 'admin', // Default mode
projectSlug: null, // Extracted from path or header
headerEnvironment: 'production', // From X-Runtime-Environment
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug
};
```
**Usage in Routes:**
```javascript
// index.js
app.use(runtimeContextMiddleware);
// Access in handlers
const env = req.runtimeContext?.headerEnvironment;
if (env === 'production') {
// Filter for production content
}
```
**Route-Based Environment Access:**
| Route | Environment | Access |
|-------|-------------|--------|
| `/p/[slug]` | `production` | Public (no auth) |
| `/p/[slug]/stage` | `stage` | Authenticated only |
| `/constructor?projectId=` | `dev` | Authenticated only |
---
### 4. runtime-public.ts (200 lines)
Middleware for controlling public runtime access and sanitizing responses.
#### Allowed Fields (Whitelist)
Only these fields are returned for public runtime requests:
```javascript
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [
'id', 'name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url',
],
tour_pages: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'sort_order', 'background_image_url', 'background_video_url',
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json',
],
project_audio_tracks: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'url', 'loop', 'volume', 'sort_order', 'is_enabled',
],
};
```
#### Function: blockNonPublicRuntimeListEndpoints
Restricts public runtime requests to list endpoints only.
```javascript
const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
if (!isPublicRuntimeReadRequest(req)) {
return next(); // Not a public request, continue
}
// Only allow root path (list endpoint)
if (req.path !== '/') {
return res.status(404).send({ message: 'Not found' });
}
// Block CSV exports
if (req.query.filetype === 'csv') {
return res.status(404).send({ message: 'Not found' });
}
return next();
};
```
**Blocked:**
- Individual record access: `GET /api/projects/123` → 404
- CSV exports: `GET /api/projects?filetype=csv` → 404
**Allowed:**
- List endpoints: `GET /api/projects/` → Continue
#### Function: sanitizePublicRuntimeListResponse(entityName)
Filters response data to only include whitelisted fields.
```javascript
const sanitizePublicRuntimeListResponse = (entityName) => {
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
return (req, res, next) => {
// Intercept res.send()
const originalSend = res.send.bind(res);
res.send = (body) => {
if (Array.isArray(body.rows)) {
const sanitizedRows = body.rows.map((row) => pickFields(row, fields));
return originalSend({ ...body, rows: sanitizedRows });
}
return originalSend(body);
};
return next();
};
};
```
**Before Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour",
"createdAt": "2024-01-01",
"createdById": "user-456",
"internalNotes": "sensitive data"
}]
}
```
**After Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour"
}]
}
```
#### Usage in index.js
```javascript
const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(
path,
requireRuntimeReadOrAuth, // JWT or public access
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
sanitizePublicRuntimeListResponse(entityName), // Filter fields
router,
);
};
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...);
```
---
### 5. upload.ts (34 lines)
Simple Multer-based file upload middleware.
```javascript
const util = require('util');
const Multer = require('multer');
let processFile = Multer({
storage: Multer.memoryStorage(),
}).single('file');
let processFileMiddleware = util.promisify(processFile);
module.exports = processFileMiddleware;
```
**Configuration:**
| Setting | Value |
|---------|-------|
| Storage | Memory (Buffer) |
| Field Name | `file` |
| Max Files | 1 (single) |
**Usage:**
```javascript
const upload = require('./middlewares/upload');
router.post('/upload', async (req, res) => {
await upload(req, res);
// req.file contains:
// - buffer: File data
// - originalname: Original filename
// - mimetype: MIME type
// - size: File size in bytes
});
```
**Note:** This middleware is primarily used for legacy uploads. The main file upload system uses chunked uploads without this middleware.
---
## Middleware Composition Patterns
### Pattern 1: Route-Level Rate Limiting
```javascript
// Apply limiter before route handler
app.use('/api/file/upload', uploadLimiter);
app.use('/api/file', fileRoutes);
```
### Pattern 2: Inline Middleware Chain
```javascript
// Multiple middlewares in route definition
router.post(
'/signin/local',
signinLimiter, // Rate limit
wrapAsync(async (req, res) => { ... }),
);
```
### Pattern 3: JWT + Feature Middleware
```javascript
// JWT auth + rate limit + routes
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
```
### Pattern 4: Conditional Auth (Runtime)
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true;
return next(); // Allow without auth
}
req.isRuntimePublicRequest = false;
return jwtAuth(req, res, next); // Require auth
};
```
### Pattern 5: Response Interception
```javascript
// Intercept and modify response before sending
const sanitizeResponse = (req, res, next) => {
const originalSend = res.send.bind(res);
res.send = (body) => {
const modified = transformBody(body);
return originalSend(modified);
};
return next();
};
```
---
## Request Flow Examples
### Example 1: Authenticated API Request
```
GET /api/users
Authorization: Bearer <jwt>
1. helmet() → Security headers
2. cors() → CORS headers
3. requestLogger → Log request
4. bodyParser.json() → Parse body
5. runtimeContextMiddleware → Set req.runtimeContext
6. jwtAuth → Validate JWT, set req.currentUser
7. checkCrudPermissions('users') → Check READ_USERS permission
8. Route handler → Return users
```
### Example 2: Public Runtime Request
```
GET /api/projects
X-Runtime-Environment: production
1. helmet() → Security headers
2. cors() → CORS headers
3. requestLogger → Log request
4. bodyParser.json() → Parse body
5. runtimeContextMiddleware → Set req.runtimeContext.headerEnvironment = 'production'
6. requireRuntimeReadOrAuth → Set req.isRuntimePublicRequest = true, skip JWT
7. blockNonPublicRuntimeListEndpoints → Allow (path is '/')
8. sanitizePublicRuntimeListResponse('projects') → Filter response fields
9. checkCrudPermissions('projects') → Skip (isRuntimePublicRequest)
10. Route handler → Return sanitized projects
```
### Example 3: Rate Limited Upload
```
POST /api/file/upload
Authorization: Bearer <jwt>
Content-Type: multipart/form-data
1. uploadLimiter → Check rate limit (10/min)
├── Under limit → Continue
└── Over limit → 429 Too Many Requests
2. fileRoutes handles request (own body parsing)
```
---
## Error Handling
### Rate Limit Errors
```javascript
// 429 Too Many Requests
{
"error": "Too Many Requests",
"message": "Too many requests. Please try again later.",
"retryAfter": 300
}
```
### Permission Errors
```javascript
// 403 Forbidden (via ValidationError)
{
"message": "Role 'User' denied access to 'DELETE_USERS'."
}
```
### Public Access Errors
```javascript
// 404 Not Found (blocked endpoint)
{
"message": "Not found"
}
```
---
## Configuration
### Environment Variables
| Variable | Affects | Description |
|----------|---------|-------------|
| `NODE_ENV` | Rate limiting | Skip localhost in development |
### Constants
```javascript
// rateLimiter.js
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
// check-permissions.ts
const METHOD_MAP = {
POST: 'CREATE',
GET: 'READ',
PUT: 'UPDATE',
PATCH: 'UPDATE',
DELETE: 'DELETE',
};
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS',
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS',
]);
// runtime-public.ts
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
```
---
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `multer` | ^1.4.5 | Multipart form data parsing |
| `util` | built-in | Promisify multer |
**Internal Dependencies:**
- `../utils/logger` - Pino logger for rate limit logging
- `../services/notifications/errors/validation` - ValidationError class
- `../db/api/roles` - RolesDBApi for Public role
---
## Security Considerations
1. **Rate Limiting:** Prevents brute force and DoS attacks
2. **Permission Checking:** RBAC with role hierarchy
3. **Public Role Fallback:** Unauthenticated users get minimal permissions
4. **Response Sanitization:** Prevents data leakage in public runtime
5. **Self-Access Bypass:** Users can always access their own resources
6. **Memory Store:** Not suitable for horizontal scaling (use Redis)
---
## Testing
### Test Rate Limiting
```bash
# Should succeed (under limit)
for i in {1..10}; do
curl -X POST http://localhost:3000/api/auth/signin/local \
-H "Content-Type: application/json" \
-d '{"email": "test@test.com", "password": "wrong"}'
done
# Should return 429 (over limit)
curl -X POST http://localhost:3000/api/auth/signin/local \
-H "Content-Type: application/json" \
-d '{"email": "test@test.com", "password": "wrong"}'
```
### Test Public Runtime Access
```bash
# Should return sanitized projects
curl http://localhost:3000/api/projects \
-H "X-Runtime-Environment: production"
# Should return 404 (individual record blocked)
curl http://localhost:3000/api/projects/123 \
-H "X-Runtime-Environment: production"
```
### Test Permission Check
```bash
# Should return 403 if user lacks permission
curl http://localhost:3000/api/users \
-H "Authorization: Bearer <limited_user_jwt>"
```
---
## Summary
The Middleware module provides:
1. **rateLimiter.js** - 8 pre-configured rate limiters with in-memory store
2. **check-permissions.ts** - RBAC through AccessPolicy with user-route-only self access
3. **runtime-context.ts** - Runtime environment context from headers
4. **runtime-public.ts** - Public access control and response sanitization
5. **upload.ts** - Simple Multer-based file upload
**Key Features:**
- Configurable rate limiting per endpoint type
- Role-based permission checking with method-to-CRUD mapping
- Public runtime access for production presentations
- Response field filtering for public access
- Memory-based storage (scales vertically, needs Redis for horizontal)