security improvements

This commit is contained in:
Dmitri 2026-07-22 15:23:10 +02:00
parent 22e9700639
commit 9b9948f2ad
62 changed files with 1511 additions and 486 deletions

View File

@ -406,6 +406,7 @@ Detailed feature documentation is available in `documentation/`:
| [assets-preloading.md](documentation/assets-preloading.md) | Asset preloading and caching strategy | | [assets-preloading.md](documentation/assets-preloading.md) | Asset preloading and caching strategy |
| [publishing-workflow.md](documentation/publishing-workflow.md) | Dev → Stage → Production publishing | | [publishing-workflow.md](documentation/publishing-workflow.md) | Dev → Stage → Production publishing |
| [private-production-presentations.md](documentation/private-production-presentations.md) | Private production presentation allowlist, viewer users, and runtime access flow | | [private-production-presentations.md](documentation/private-production-presentations.md) | Private production presentation allowlist, viewer users, and runtime access flow |
| [security-assessment-2026-07-22.md](documentation/security-assessment-2026-07-22.md) | Post-remediation security assessment, accepted risks, and verification results |
| [offline-pwa-mode.md](documentation/offline-pwa-mode.md) | PWA offline capabilities and caching | | [offline-pwa-mode.md](documentation/offline-pwa-mode.md) | PWA offline capabilities and caching |
| [email-notification-service.md](documentation/email-notification-service.md) | Nodemailer/SES integration, verification emails, invitations | | [email-notification-service.md](documentation/email-notification-service.md) | Nodemailer/SES integration, verification emails, invitations |
| [search-system.md](documentation/search-system.md) | Global full-text search, permission-based filtering | | [search-system.md](documentation/search-system.md) | Global full-text search, permission-based filtering |

View File

@ -4,6 +4,7 @@ DB_PASS=d82cf4a2-477c-4a75-acec-ec606e0b8a01
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=5432 DB_PORT=5432
PORT=3000 PORT=3000
UI_URL=https://tbp.flatlogic.app
GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com GOOGLE_CLIENT_ID=671001533244-kf1k1gmp6mnl0r030qmvdu6v36ghmim6.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR GOOGLE_CLIENT_SECRET=Yo4qbKZniqvojzUQ60iKlxqR
MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73 MS_CLIENT_ID=4696f457-31af-40de-897c-e00d7d4cff73

View File

@ -323,8 +323,6 @@ Health check endpoint.
{ {
"status": "ok", "status": "ok",
"timestamp": "2026-03-30T12:00:00.000Z", "timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678,
"environment": "production",
"database": "connected" "database": "connected"
} }
``` ```
@ -335,10 +333,7 @@ Health check endpoint.
{ {
"status": "degraded", "status": "degraded",
"timestamp": "2026-03-30T12:00:00.000Z", "timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678, "database": "disconnected"
"environment": "production",
"database": "disconnected",
"databaseError": "Connection refused"
} }
``` ```
@ -943,17 +938,19 @@ Environment-aware CSS transition settings for page navigation.
### Authentication Model ### Authentication Model
Uses **URL-path-based public access** - no headers required: The project ID in the URL identifies the presentation for access checks. Runtime
headers are optional for this endpoint.
| Endpoint | Method | Environment | Auth Required | | Endpoint | Method | Environment | Auth Required |
| ----------------------------- | ---------- | ----------- | ----------------------------------- | | ----------------------------- | ---------- | ----------- | ----------------------------------- |
| `/project/:id/env/production` | GET | production | **No** (public) | | `/project/:id/env/production` | GET | production | No for public projects; JWT for private projects |
| `/project/:id/env/dev` | GET | dev | JWT + READ_PAGE_ELEMENTS | | `/project/:id/env/dev` | GET | dev | JWT + READ_PAGE_ELEMENTS |
| `/project/:id/env/stage` | GET | stage | JWT + READ_PAGE_ELEMENTS | | `/project/:id/env/stage` | GET | stage | JWT + READ_PAGE_ELEMENTS |
| `/project/:id/env/*` | PUT/DELETE | any | JWT + UPDATE_PAGE_ELEMENTS | | `/project/:id/env/*` | PUT/DELETE | any | JWT + UPDATE_PAGE_ELEMENTS |
| Standard CRUD | all | n/a | JWT + PAGE_ELEMENTS CRUD permission | | Standard CRUD | all | n/a | JWT + PAGE_ELEMENTS CRUD permission |
This allows public presentations (`/p/[slug]`) to fetch production transition settings without authentication in incognito mode. Public presentations can fetch production transition settings anonymously.
Private presentations require a staff permission or an explicit viewer grant.
### Standard CRUD Endpoints (All Require Auth) ### Standard CRUD Endpoints (All Require Auth)
@ -969,7 +966,7 @@ This allows public presentations (`/p/[slug]`) to fetch production transition se
| Method | Endpoint | Auth | Description | | Method | Endpoint | Auth | Description |
| ------ | -------------------------------------- | -------------------------- | --------------------------------- | | ------ | -------------------------------------- | -------------------------- | --------------------------------- |
| GET | `/project/:projectId/env/production` | None | Get production settings (public) | | GET | `/project/:projectId/env/production` | None for public; JWT for private | Get production settings |
| GET | `/project/:projectId/env/dev` | JWT | Get dev settings | | GET | `/project/:projectId/env/dev` | JWT | Get dev settings |
| GET | `/project/:projectId/env/stage` | JWT | Get stage settings | | GET | `/project/:projectId/env/stage` | JWT | Get stage settings |
| PUT | `/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Create or update (upsert) | | PUT | `/project/:projectId/env/:environment` | JWT + UPDATE_PAGE_ELEMENTS | Create or update (upsert) |
@ -984,7 +981,7 @@ This allows public presentations (`/p/[slug]`) to fetch production transition se
**Authentication:** **Authentication:**
- `production`: None required (public access) - `production`: none for public projects; JWT plus staff access or a viewer grant for private projects
- `dev`/`stage`: `Authorization: Bearer {token}` - `dev`/`stage`: `Authorization: Bearer {token}`
**Response (200):** **Response (200):**
@ -1317,7 +1314,9 @@ Download a file from storage. Supports automatic client disconnect handling via
Generate presigned URLs for direct S3 access. Includes path validation to prevent directory traversal attacks. Generate presigned URLs for direct S3 access. Includes path validation to prevent directory traversal attacks.
**Authentication**: None **Authentication**: Optional JWT for internal staff. Other callers need an
accessible production runtime context via `X-Runtime-Environment` and
`X-Runtime-Project-Slug`.
**Rate Limit**: 200/min **Rate Limit**: 200/min
**Request:** **Request:**
@ -1361,6 +1360,8 @@ URLs are validated to prevent path traversal and ensure security:
| 400 | `urls` array is empty | | 400 | `urls` array is empty |
| 400 | `urls` exceeds maximum of 50 | | 400 | `urls` exceeds maximum of 50 |
| 400 | Invalid URL format (contains `..`, starts with `/`, etc.) | | 400 | Invalid URL format (contains `..`, starts with `/`, etc.) |
| 401 | Missing staff authentication or production runtime context |
| 403 | Presentation access denied or storage key is not used by the presentation |
| 500 | S3 presigning failed | | 500 | S3 presigning failed |
| 503 | S3 service unavailable | | 503 | S3 service unavailable |

View File

@ -299,7 +299,8 @@ Request → requestLogger → runtimeContextMiddleware → rateLimiter
Application bootstrap: Application bootstrap:
1. **Security**: Helmet, CORS configuration 1. **Security**: Helmet with CSP, allowlisted non-credentialed CORS, and
loopback-only reverse-proxy trust
2. **Logging**: Request logger middleware (Pino) 2. **Logging**: Request logger middleware (Pino)
3. **Authentication**: Passport JWT initialization 3. **Authentication**: Passport JWT initialization
4. **Rate Limiting**: Per-route rate limiters 4. **Rate Limiting**: Per-route rate limiters
@ -441,17 +442,23 @@ const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
For production content accessible without authentication: For production content accessible without authentication:
```javascript ```javascript
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = async (req, res, next) => {
const isPublicEnvironment = const environment = req.runtimeContext?.headerEnvironment;
req.runtimeContext?.headerEnvironment === 'production'; const projectSlug = normalizeSlug(req.runtimeContext?.headerProjectSlug);
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { if (environment !== 'production' || !isReadOnlyRequest) {
req.isRuntimePublicRequest = true; return jwtAuth(req, res, next);
return next(); // Allow without JWT
} }
if (!projectSlug) return res.status(400).send({ message: 'Runtime project slug is required' });
return jwtAuth(req, res, next); // Require JWT const project = await getProjectBySlug(projectSlug);
if (!project) return res.status(404).send({ message: 'Presentation not found' });
if (project.production_presentation_visibility === 'public') {
req.isRuntimePublicRequest = true;
return next();
}
return authenticatePrivatePresentation(req, res, next, projectSlug);
}; };
``` ```
@ -676,8 +683,6 @@ GET /api/health
{ {
"status": "ok", // or "degraded" "status": "ok", // or "degraded"
"timestamp": "2026-03-29T...", "timestamp": "2026-03-29T...",
"uptime": 12345.678,
"environment": "production",
"database": "connected" // or "disconnected" "database": "connected" // or "disconnected"
} }
``` ```

View File

@ -138,18 +138,16 @@ import {
```javascript ```javascript
// Order matters - applied in this sequence: // Order matters - applied in this sequence:
1. swaggerUI.serve // API documentation at /api-docs 1. helmet() // Security headers
2. helmet() // Security headers 2. cors(createCorsOptions(...)) // Configured UI origin allowlist
3. cors({ origin: true }) // Cross-origin requests 3. swaggerUI.serve // API documentation at /api-docs
4. requestLogger // Request logging (Pino) 4. requestLogger // Request logging (Pino)
5. downloadLimiter // Rate limit for /api/file/download, /api/file/presign 5. runtimeContextMiddleware // Environment and project context
6. uploadLimiter // Rate limit for /api/file/upload* 6. file rate limiters + file router // Mounted before global body parsing
7. bodyParser.json() // JSON parsing (1MB limit) 7. bodyParser.json/urlencoded // Parsing for remaining routes
8. bodyParser.urlencoded() // Form data parsing 8. passport.authenticate() // JWT authentication (per-route)
9. runtimeContextMiddleware // Environment detection 9. checkPermissions // RBAC (per-route)
10. passport.authenticate() // JWT authentication (per-route) 10. errorHandler // Generic error handling
11. checkPermissions // RBAC (per-route)
12. errorHandler // Generic error handling
``` ```
### Route Mounting ### Route Mounting
@ -160,7 +158,7 @@ import {
app.get('/api/health', ...) // Health check app.get('/api/health', ...) // Health check
app.use('/api/auth', authRoutes) // Authentication app.use('/api/auth', authRoutes) // Authentication
app.use('/api/runtime-context', ...) // Runtime context app.use('/api/runtime-context', ...) // Runtime context
app.use('/api/file', fileRoutes) // File download/presign (partial) app.use('/api/file', fileRoutes) // Public download; scoped presign; protected uploads
``` ```
#### Protected Routes (JWT Required) #### Protected Routes (JWT Required)
@ -209,21 +207,23 @@ mountRuntimeEntityRoute(
Middleware that allows public read access for production content: Middleware that allows public read access for production content:
```javascript ```javascript
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = async (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment; const headerEnvironment = req.runtimeContext?.headerEnvironment;
const projectSlug = normalizeSlug(req.runtimeContext?.headerProjectSlug);
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
// Only production is public. Stage requires authentication. if (headerEnvironment !== 'production' || !isReadOnlyRequest) {
const isPublicEnvironment = headerEnvironment === 'production'; return jwtAuth(req, res, next);
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true;
return next(); // Allow without JWT
} }
if (!projectSlug) return res.status(400).send({ message: 'Runtime project slug is required' });
req.isRuntimePublicRequest = false; const project = await getProjectBySlug(projectSlug);
return jwtAuth(req, res, next); // Require JWT if (!project) return res.status(404).send({ message: 'Presentation not found' });
if (project.production_presentation_visibility === 'public') {
req.isRuntimePublicRequest = true;
return next();
}
return authenticatePrivatePresentation(req, res, next, projectSlug);
}; };
``` ```
@ -263,8 +263,6 @@ GET /api/health
{ {
"status": "ok", "status": "ok",
"timestamp": "2026-03-30T12:00:00.000Z", "timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678,
"environment": "production",
"database": "connected" "database": "connected"
} }
@ -272,10 +270,7 @@ GET /api/health
{ {
"status": "degraded", "status": "degraded",
"timestamp": "2026-03-30T12:00:00.000Z", "timestamp": "2026-03-30T12:00:00.000Z",
"uptime": 12345.678, "database": "disconnected"
"environment": "production",
"database": "disconnected",
"databaseError": "Connection refused"
} }
``` ```
@ -419,16 +414,6 @@ const config = {
// JWT // JWT
secret_key: env.SECRET_KEY, secret_key: env.SECRET_KEY,
// Server URLs
remote: '',
port,
hostUI,
portUI,
// Swagger
swaggerUI,
swaggerPort,
// OAuth // OAuth
google: { google: {
clientId: env.GOOGLE_CLIENT_ID, clientId: env.GOOGLE_CLIENT_ID,
@ -464,8 +449,10 @@ const config = {
server: { server: {
env: env.NODE_ENV, env: env.NODE_ENV,
port: serverPort, port: serverPort,
swaggerServerUrl, swaggerServerUrl: publicBackendOrigin,
}, },
apiUrl: `${publicBackendOrigin}/api`,
uiUrl,
}; };
``` ```
@ -475,10 +462,11 @@ const config = {
| ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- | | ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- |
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` | | `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
| `PORT` | number | `8080` | Server port | | `PORT` | number | `8080` | Server port |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) | | `UI_URL` | string | local UI outside production | Canonical UI origin; required in production |
| `SECRET_KEY` | string | none | Required JWT signing key (min 32 chars) |
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email | | `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
| `ADMIN_PASS` | string | Generated | Admin user password | | `ADMIN_PASS` | string | empty | Required only when creating missing seed users |
| `USER_PASS` | string | Generated | Default user password | | `USER_PASS` | string | empty | Required only when creating missing seed users |
| `AWS_S3_BUCKET` | string | - | S3 bucket name | | `AWS_S3_BUCKET` | string | - | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region | | `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key | | `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
@ -507,15 +495,22 @@ const envSchema = Joi.object({
PORT: Joi.number().default(8080), PORT: Joi.number().default(8080),
UI_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.allow('')
.default('')
.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(1).required(),
}),
DB_HOST: Joi.string().default('localhost'), DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432), DB_PORT: Joi.number().default(5432),
DB_NAME: Joi.string().default('db_tour_builder_platform'), DB_NAME: Joi.string().default('db_tour_builder_platform'),
DB_USER: Joi.string().default('postgres'), DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''), DB_PASS: Joi.string().allow('').default(''),
SECRET_KEY: Joi.string() SECRET_KEY: Joi.string().min(32).required(),
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
// ... more validations // ... more validations
}).unknown(true); }).unknown(true);

View File

@ -6,7 +6,7 @@ The DB API module provides the data access layer that sits between services and
**Location:** `backend/src/db/api/` **Location:** `backend/src/db/api/`
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility) **Files:** Includes entity APIs and focused runtime query modules.
| File | Class/Purpose | LOC | Extends GenericDBApi | | File | Class/Purpose | LOC | Extends GenericDBApi |
| -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- | | -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- |
@ -32,6 +32,7 @@ The DB API module provides the data access layer that sits between services and
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes | | `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) | | `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
| `runtime-context.ts` | Runtime context helpers | 57 | - | | `runtime-context.ts` | Runtime context helpers | 57 | - |
| `runtime-asset-access.ts` | Production presentation asset-reference lookup | ~75 | No (custom) |
--- ---

View File

@ -97,6 +97,14 @@ const envSchema = Joi.object({
.default('development'), .default('development'),
PORT: Joi.number().default(8080), PORT: Joi.number().default(8080),
UI_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.allow('')
.default('')
.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(1).required(),
}),
DB_HOST: Joi.string().default('localhost'), DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432), DB_PORT: Joi.number().default(5432),
@ -104,9 +112,7 @@ const envSchema = Joi.object({
DB_USER: Joi.string().default('postgres'), DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''), DB_PASS: Joi.string().allow('').default(''),
SECRET_KEY: Joi.string() SECRET_KEY: Joi.string().min(32).required(),
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
// ... more variables // ... more variables
}).unknown(true); }).unknown(true);
@ -118,14 +124,15 @@ const envSchema = Joi.object({
| ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ | | ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ |
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development | | **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
| | PORT | number | 8080 | | | PORT | number | 8080 |
| | UI_URL | HTTP(S) origin; required in production | (empty outside production) |
| **Database** | DB_HOST | string | localhost | | **Database** | DB_HOST | string | localhost |
| | DB_PORT | number | 5432 | | | DB_PORT | number | 5432 |
| | DB_NAME | string | db_tour_builder_platform | | | DB_NAME | string | db_tour_builder_platform |
| | DB_USER | string | postgres | | | DB_USER | string | postgres |
| | DB_PASS | string (allow empty) | (empty) | | | DB_PASS | string (allow empty) | (empty) |
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) | | **Auth** | SECRET_KEY | required string, min 32 chars | none |
| | ADMIN_PASS | string | 88dbeaf8 | | | ADMIN_PASS | string (allow empty) | (empty) |
| | USER_PASS | string | c3baadeda5c6 | | | USER_PASS | string (allow empty) | (empty) |
| | ADMIN_EMAIL | email | admin@flatlogic.com | | | ADMIN_EMAIL | email | admin@flatlogic.com |
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) | | **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) | | | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
@ -378,7 +385,7 @@ const config = {
server: { server: {
env: env.NODE_ENV, env: env.NODE_ENV,
port: serverPort, port: serverPort,
swaggerServerUrl, swaggerServerUrl: publicBackendOrigin,
}, },
secret_key: env.SECRET_KEY, secret_key: env.SECRET_KEY,
admin_pass: env.ADMIN_PASS, admin_pass: env.ADMIN_PASS,
@ -390,10 +397,8 @@ const config = {
admin: 'Administrator', admin: 'Administrator',
user: 'Analytics Viewer', user: 'Analytics Viewer',
}, },
apiUrl: `${host}${port ? `:${port}` : ''}/api`, apiUrl: `${publicBackendOrigin}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`, uiUrl, // normalized UI_URL origin or local development default
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,
backUrl: `${hostUI}${portUI ? `:${portUI}` : ''}`,
}; };
``` ```
@ -425,17 +430,19 @@ const config = {
| Setting | Value | Purpose | | Setting | Value | Purpose |
| ----------------------------- | --------------- | -------------------------- | | ----------------------------- | --------------- | -------------------------- |
| bcrypt.saltRounds | 12 | Password hashing strength | | bcrypt.saltRounds | 12 | Password hashing strength |
| SECRET_KEY | 16+ char string | JWT signing key | | SECRET_KEY | 32+ char string | JWT signing key |
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation | | EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
### URL Configuration ### URL Configuration
| URL | Development | Production | | Setting | Without `UI_URL` | Standard VM |
| ---------- | ------------------------- | ------------ | | ------------------ | ------------------------------- | -------------------------------- |
| apiUrl | http://localhost:3000/api | (remote)/api | | `uiUrl` | `http://localhost:3001` | `https://tbp.flatlogic.app` |
| swaggerUrl | http://localhost:3000 | (remote) | | `apiUrl` | Local backend origin plus `/api`| `https://tbp.flatlogic.app/api` |
| uiUrl | http://localhost:3001/# | (remote)/# | | `swaggerServerUrl` | Local backend origin | `https://tbp.flatlogic.app` |
| backUrl | http://localhost:3001 | (remote) |
`UI_URL` is the only backend public-origin setting. OAuth callbacks and the
direct CORS allowlist use it, and the API remains same-origin under `/api`.
--- ---

View File

@ -656,19 +656,23 @@ app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
### Pattern 4: Conditional Auth (Runtime) ### Pattern 4: Conditional Auth (Runtime)
```javascript ```javascript
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = async (req, res, next) => {
const headerEnvironment = req.runtimeContext?.headerEnvironment; const headerEnvironment = req.runtimeContext?.headerEnvironment;
const projectSlug = normalizeSlug(req.runtimeContext?.headerProjectSlug);
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
const isPublicEnvironment = headerEnvironment === 'production';
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { if (headerEnvironment !== 'production' || !isReadOnlyRequest) {
req.isRuntimePublicRequest = true; return jwtAuth(req, res, next);
return next(); // Allow without auth
} }
if (!projectSlug) return res.status(400).send({ message: 'Runtime project slug is required' });
req.isRuntimePublicRequest = false; const project = await getProjectBySlug(projectSlug);
return jwtAuth(req, res, next); // Require auth if (!project) return res.status(404).send({ message: 'Presentation not found' });
if (project.production_presentation_visibility === 'public') {
req.isRuntimePublicRequest = true;
return next();
}
return authenticatePrivatePresentation(req, res, next, projectSlug);
}; };
``` ```
@ -713,12 +717,13 @@ Authorization: Bearer <jwt>
``` ```
GET /api/projects GET /api/projects
X-Runtime-Environment: production X-Runtime-Environment: production
X-Runtime-Project-Slug: my-tour
1. helmet() → Security headers 1. helmet() → Security headers
2. cors() → CORS headers 2. cors() → CORS headers
3. requestLogger → Log request 3. requestLogger → Log request
4. bodyParser.json() → Parse body 4. bodyParser.json() → Parse body
5. runtimeContextMiddleware → Set req.runtimeContext.headerEnvironment = 'production' 5. runtimeContextMiddleware → Set production environment and project slug
6. requireRuntimeReadOrAuth → Set req.isRuntimePublicRequest = true, skip JWT 6. requireRuntimeReadOrAuth → Set req.isRuntimePublicRequest = true, skip JWT
7. blockNonPublicRuntimeListEndpoints → Allow (path is '/') 7. blockNonPublicRuntimeListEndpoints → Allow (path is '/')
8. sanitizePublicRuntimeListResponse('projects') → Filter response fields 8. sanitizePublicRuntimeListResponse('projects') → Filter response fields
@ -835,6 +840,12 @@ const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
4. **Response Sanitization:** Prevents data leakage in public runtime 4. **Response Sanitization:** Prevents data leakage in public runtime
5. **Self-Access Bypass:** Users can always access their own resources 5. **Self-Access Bypass:** Users can always access their own resources
6. **Memory Store:** Not suitable for horizontal scaling (use Redis) 6. **Memory Store:** Not suitable for horizontal scaling (use Redis)
7. **Proxy Trust:** Only loopback reverse proxies are trusted. The standard VM
keeps the local tunnel's forwarding chain, and Express selects the first
untrusted hop from the right. Direct Nginx and DNS-only Apache deployments
replace supplied forwarding chains with the socket client address.
8. **CORS:** Untrusted origins receive no cross-origin read permission. Local
frontend development origins are allowed without credentialed CORS.
--- ---
@ -861,11 +872,13 @@ curl -X POST http://localhost:3000/api/auth/signin/local \
```bash ```bash
# Should return sanitized projects # Should return sanitized projects
curl http://localhost:3000/api/projects \ curl http://localhost:3000/api/projects \
-H "X-Runtime-Environment: production" -H "X-Runtime-Environment: production" \
-H "X-Runtime-Project-Slug: my-tour"
# Should return 404 (individual record blocked) # Should return 404 (individual record blocked)
curl http://localhost:3000/api/projects/123 \ curl http://localhost:3000/api/projects/123 \
-H "X-Runtime-Environment: production" -H "X-Runtime-Environment: production" \
-H "X-Runtime-Project-Slug: my-tour"
``` ```
### Test Permission Check ### Test Permission Check

View File

@ -263,7 +263,7 @@ File upload and download operations.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
| ------ | ------------------------------------------------ | ---- | -------------------------------- | | ------ | ------------------------------------------------ | ---- | -------------------------------- |
| GET | `/download` | No | Download file by privateUrl | | GET | `/download` | No | Download file by privateUrl |
| POST | `/presign` | No | Generate presigned URLs (max 50) | | POST | `/presign` | Optional JWT or production context | Generate presentation-scoped presigned URLs (max 50) |
| POST | `/upload/:table/:field` | JWT | Legacy single file upload | | POST | `/upload/:table/:field` | JWT | Legacy single file upload |
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload | | POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status | | GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
@ -272,6 +272,10 @@ File upload and download operations.
**Presigned URLs Request:** **Presigned URLs Request:**
Internal staff use an optional JWT. Anonymous and Public-user requests must
provide production runtime headers, and every requested key must belong to the
accessible presentation.
```json ```json
{ {
"urls": ["assets/image.jpg", "assets/video.mp4"] "urls": ["assets/image.jpg", "assets/video.mp4"]
@ -681,7 +685,6 @@ router.use('/', require('../helpers').commonErrorHandler);
| `GET /api/auth/signin/google` | Google OAuth | | `GET /api/auth/signin/google` | Google OAuth |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth | | `GET /api/auth/signin/microsoft` | Microsoft OAuth |
| `GET /api/file/download` | File download | | `GET /api/file/download` | File download |
| `POST /api/file/presign` | Generate presigned URLs |
| `GET /api/runtime-context` | Runtime context | | `GET /api/runtime-context` | Runtime context |
### Runtime Public Routes (Production Environment) ### Runtime Public Routes (Production Environment)
@ -755,13 +758,16 @@ curl -X DELETE http://localhost:3000/api/projects/<uuid> \
```bash ```bash
curl http://localhost:3000/api/projects \ curl http://localhost:3000/api/projects \
-H "X-Runtime-Environment: production" -H "X-Runtime-Environment: production" \
-H "X-Runtime-Project-Slug: my-tour"
``` ```
### Test Presigned URLs ### Test Presigned URLs
```bash ```bash
curl -X POST http://localhost:3000/api/file/presign \ curl -X POST http://localhost:3000/api/file/presign \
-H "X-Runtime-Environment: production" \
-H "X-Runtime-Project-Slug: my-tour" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"urls": ["assets/test.jpg"]}' -d '{"urls": ["assets/test.jpg"]}'
``` ```

View File

@ -312,6 +312,12 @@ const createErrorResponse = (message, code, details) → { message, code?, detai
const getS3ErrorStatusCode = (error) → number // HTTP status code mapping const getS3ErrorStatusCode = (error) → number // HTTP status code mapping
``` ```
`runtime-asset-access.ts` authorizes presign batches. Staff with effective
admin permissions have all-project access. Other requests need an accessible
production presentation, and each requested key must appear in that
presentation's assets, variants, branding, pages, page schema, or audio tracks.
Its database reads live in `db/api/runtime-asset-access.ts`.
**Server-Side File Copy (S3 Native):** **Server-Side File Copy (S3 Native):**
The `copyFile()` function uses provider-native copy operations for optimal performance: The `copyFile()` function uses provider-native copy operations for optimal performance:

View File

@ -3723,9 +3723,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5252,9 +5252,9 @@
} }
}, },
"node_modules/express/node_modules/body-parser": { "node_modules/express/node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@ -7237,9 +7237,9 @@
} }
}, },
"node_modules/nodemon/node_modules/brace-expansion": { "node_modules/nodemon/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -65,6 +65,9 @@
"node": ">=24 <25" "node": ">=24 <25"
}, },
"overrides": { "overrides": {
"express": {
"body-parser": "1.20.6"
},
"gaxios": { "gaxios": {
"uuid": "^11.1.1" "uuid": "^11.1.1"
}, },

View File

@ -8,18 +8,6 @@ import type { BackendConfig } from './types/index.ts';
const env = validateEnv(); const env = validateEnv();
const isProduction = env.NODE_ENV === 'production'; const isProduction = env.NODE_ENV === 'production';
const remote = '';
const port = isProduction ? '' : '8080';
const hostUI = isProduction ? '' : 'http://localhost';
const portUI = isProduction ? '' : '3000';
const swaggerUI = isProduction ? '' : 'http://localhost';
const swaggerPort = isProduction ? '' : ':8080';
const host = isProduction ? remote : 'http://localhost';
const getBaseUrl = (url: string): string => {
if (!url) return '';
return url.endsWith('/api') ? url.slice(0, -4) : url;
};
const getServerPort = (): number => { const getServerPort = (): number => {
if (process.env.PORT !== undefined) { if (process.env.PORT !== undefined) {
@ -30,8 +18,17 @@ const getServerPort = (): number => {
}; };
const serverPort = getServerPort(); const serverPort = getServerPort();
const swaggerServerUrl = const localBackendOrigin = `http://localhost:${serverPort}`;
getBaseUrl(env.NEXT_PUBLIC_BACK_API) || `${swaggerUI}${swaggerPort}`;
const normalizeOrigin = (value: string): string => {
if (!value) return '';
return new URL(value).origin;
};
const uiUrl = normalizeOrigin(
env.UI_URL || (isProduction ? '' : 'http://localhost:3001'),
);
const publicBackendOrigin = uiUrl || localBackendOrigin;
const config: BackendConfig = { const config: BackendConfig = {
gcloud: { gcloud: {
@ -87,14 +84,6 @@ const config: BackendConfig = {
MICROSOFT: 'microsoft', MICROSOFT: 'microsoft',
}, },
secret_key: env.SECRET_KEY, secret_key: env.SECRET_KEY,
remote,
port,
host,
hostUI,
portUI,
portUIProd: isProduction ? '' : ':3000',
swaggerUI,
swaggerPort,
google: { google: {
clientId: env.GOOGLE_CLIENT_ID, clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET, clientSecret: env.GOOGLE_CLIENT_SECRET,
@ -126,12 +115,10 @@ const config: BackendConfig = {
server: { server: {
env: env.NODE_ENV, env: env.NODE_ENV,
port: serverPort, port: serverPort,
swaggerServerUrl, swaggerServerUrl: publicBackendOrigin,
}, },
apiUrl: `${host}${port ? `:${port}` : ''}/api`, apiUrl: `${publicBackendOrigin}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`, uiUrl,
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,
backUrl: `${hostUI}${portUI ? `:${portUI}` : ''}`,
}; };
export default config; export default config;

View File

@ -0,0 +1,92 @@
import db from '../models/index.ts';
import type { Model, ModelStatic } from 'sequelize';
interface RuntimeAssetReferenceRows {
projectRow: unknown;
assets: unknown[];
variants: unknown[];
pages: unknown[];
audioTracks: unknown[];
}
interface FindPresentationAssetReferencesOptions {
projectId: string;
}
function isRuntimeAssetModel(value: unknown): value is ModelStatic<Model> {
return (
typeof value === 'function' &&
'findAll' in value &&
typeof value.findAll === 'function' &&
'findByPk' in value &&
typeof value.findByPk === 'function'
);
}
function getRuntimeAssetModel(value: unknown, name: string): ModelStatic<Model> {
if (
!isRuntimeAssetModel(value)
) {
throw new Error(`Database model '${name}' is unavailable.`);
}
return value;
}
export default class RuntimeAssetAccessDBApi {
static async findPresentationAssetReferences(
options: FindPresentationAssetReferencesOptions,
): Promise<RuntimeAssetReferenceRows> {
const { projectId } = options;
const projects = getRuntimeAssetModel(db.projects, 'projects');
const assetsModel = getRuntimeAssetModel(db.assets, 'assets');
const variantsModel = getRuntimeAssetModel(
db.asset_variants,
'asset_variants',
);
const pagesModel = getRuntimeAssetModel(db.tour_pages, 'tour_pages');
const audioTracksModel = getRuntimeAssetModel(
db.project_audio_tracks,
'project_audio_tracks',
);
const [projectRow, assets, variants, pages, audioTracks] =
await Promise.all([
projects.findByPk(projectId, {
attributes: ['logo_url', 'favicon_url', 'og_image_url'],
}),
assetsModel.findAll({
where: { projectId },
attributes: ['storage_key', 'cdn_url'],
}),
variantsModel.findAll({
attributes: ['storage_key', 'cdn_url'],
include: [
{
association: 'asset',
attributes: [],
required: true,
where: { projectId },
},
],
}),
pagesModel.findAll({
where: { projectId, environment: 'production' },
attributes: [
'background_image_url',
'background_video_url',
'background_audio_url',
'ui_schema_json',
],
}),
audioTracksModel.findAll({
where: { projectId, environment: 'production' },
attributes: ['source_key', 'url'],
}),
]);
return { projectRow, assets, variants, pages, audioTracks };
}
}
export type { RuntimeAssetReferenceRows };

View File

@ -67,6 +67,17 @@ const adminUserSeeder: SequelizeSeeder = {
}, },
); );
const existingIds = new Set(existingRows.map((row) => row.id)); const existingIds = new Set(existingRows.map((row) => row.id));
if (existingIds.size === seedUserIds.length) {
return;
}
if (!config.admin_pass || !config.user_pass) {
throw new Error(
'ADMIN_PASS and USER_PASS are required when creating seed users.',
);
}
const rowsToInsert = createAdminUserRows().filter( const rowsToInsert = createAdminUserRows().filter(
(row) => !existingIds.has(row.id), (row) => !existingIds.has(row.id),
); );

View File

@ -71,6 +71,7 @@ import {
setCurrentUser, setCurrentUser,
setRuntimePublicRequest, setRuntimePublicRequest,
} from './utils/request-context.ts'; } from './utils/request-context.ts';
import { createCorsOptions } from './utils/cors.ts';
const app = express(); const app = express();
@ -164,19 +165,35 @@ const specs = createOpenApiDocument({
serverUrl: config.server.swaggerServerUrl, serverUrl: config.server.swaggerServerUrl,
}); });
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs)); // The standard tunnel and Apache route reach Express over loopback. Trust only
// that final proxy hop so client-supplied forwarding chains cannot choose req.ip.
app.enable('trust proxy'); app.set('trust proxy', 'loopback');
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: false, contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https:'],
imgSrc: ["'self'", 'data:', 'https:'],
fontSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https:'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'self'"],
formAction: ["'self'"],
upgradeInsecureRequests: null,
},
},
crossOriginEmbedderPolicy: false, crossOriginEmbedderPolicy: false,
}), }),
); );
app.use(cors({ origin: true, credentials: true })); app.use(cors(createCorsOptions(config.uiUrl, config.server.env)));
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
// Request logger applied early so all routes are logged // Request logger applied early so all routes are logged
app.use(requestLogger); app.use(requestLogger);
app.use(runtimeContextMiddleware);
// Initialize passport JWT auth early (before file routes) // Initialize passport JWT auth early (before file routes)
const jwtAuth = authenticateJwt(); const jwtAuth = authenticateJwt();
@ -193,7 +210,6 @@ app.use('/api/file', fileRoutes);
// Body parser for all other routes // Body parser for all other routes
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync( const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
async (req, res, next) => { async (req, res, next) => {
@ -213,10 +229,26 @@ const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
return; return;
} }
const isPrivateProductionPresentation = const normalizedProjectSlug =
await RuntimePresentationAccessService.isPrivateProductionPresentation( RuntimePresentationAccessService.normalizeSlug(headerProjectSlug);
headerProjectSlug, if (!normalizedProjectSlug) {
setRuntimePublicRequest(req, false);
res.status(400).send({ message: 'Runtime project slug is required' });
return;
}
const runtimeProject =
await RuntimePresentationAccessService.getProjectBySlug(
normalizedProjectSlug,
); );
if (!runtimeProject) {
setRuntimePublicRequest(req, false);
res.status(404).send({ message: 'Presentation not found' });
return;
}
const isPrivateProductionPresentation =
runtimeProject.production_presentation_visibility === 'private';
if (!isPrivateProductionPresentation) { if (!isPrivateProductionPresentation) {
setRuntimePublicRequest(req, true); setRuntimePublicRequest(req, true);
@ -245,7 +277,7 @@ const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
const canAccess = const canAccess =
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation( await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
user, user,
headerProjectSlug, normalizedProjectSlug,
); );
if (!canAccess) { if (!canAccess) {
@ -276,18 +308,14 @@ app.get(
const health: HealthResponse = { const health: HealthResponse = {
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: config.server.env,
}; };
try { try {
await db.sequelize.authenticate(); await db.sequelize.authenticate();
health.database = 'connected'; health.database = 'connected';
} catch (error) { } catch {
health.status = 'degraded'; health.status = 'degraded';
health.database = 'disconnected'; health.database = 'disconnected';
health.databaseError =
error instanceof Error ? error.message : 'Unknown database error';
} }
const statusCode = health.status === 'ok' ? 200 : 503; const statusCode = health.status === 'ok' ? 200 : 503;

View File

@ -845,14 +845,11 @@ const schemas: Record<string, OpenApiSchema> = {
}, },
HealthResponse: { HealthResponse: {
type: 'object', type: 'object',
required: ['status', 'timestamp', 'uptime', 'environment'], required: ['status', 'timestamp'],
properties: { properties: {
status: { type: 'string', enum: ['ok', 'degraded'] }, status: { type: 'string', enum: ['ok', 'degraded'] },
timestamp: dateTimeSchema, timestamp: dateTimeSchema,
uptime: { type: 'number' },
environment: { type: 'string' },
database: { type: 'string', enum: ['connected', 'disconnected'] }, database: { type: 'string', enum: ['connected', 'disconnected'] },
databaseError: { type: 'string' },
}, },
}, },
FilePresignRequest: { FilePresignRequest: {

View File

@ -54,9 +54,7 @@ function safeParseUrl(value: unknown): URL | null {
function getRequestHost(req: Request): string { function getRequestHost(req: Request): string {
const uiUrl = safeParseUrl(config.uiUrl); const uiUrl = safeParseUrl(config.uiUrl);
const fallbackHost = uiUrl const fallbackHost = uiUrl?.origin ?? 'http://localhost:3001';
? uiUrl.origin
: (config.backUrl ?? 'http://localhost:3000');
const origin = safeParseUrl(req.headers.origin); const origin = safeParseUrl(req.headers.origin);
const referer = safeParseUrl(req.headers.referer); const referer = safeParseUrl(req.headers.referer);

View File

@ -3,10 +3,14 @@ import express from 'express';
import type { RequestHandler } from 'express'; import type { RequestHandler } from 'express';
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import { authenticateJwt } from '../auth/passport-middleware.ts'; import {
authenticateJwt,
authenticateJwtWithCallback,
} from '../auth/passport-middleware.ts';
import { commonErrorHandler, wrapAsync } from '../helpers.ts'; import { commonErrorHandler, wrapAsync } from '../helpers.ts';
import { validateRequest } from '../middlewares/validate-request.ts'; import { validateRequest } from '../middlewares/validate-request.ts';
import services from '../services/file/index.ts'; import services from '../services/file/index.ts';
import RuntimeAssetAccessService from '../services/runtime-asset-access.ts';
import type { import type {
FileErrorResponse, FileErrorResponse,
FilePresignRequest, FilePresignRequest,
@ -17,7 +21,12 @@ import type {
UploadSessionParams, UploadSessionParams,
} from '../types/index.ts'; } from '../types/index.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import { getRequestLogger } from '../utils/request-context.ts'; import {
getCurrentUser,
getRequestLogger,
getRuntimeContext,
setCurrentUser,
} from '../utils/request-context.ts';
import { file as fileSchemas } from '../validators/request-schemas.ts'; import { file as fileSchemas } from '../validators/request-schemas.ts';
const router = express.Router(); const router = express.Router();
@ -33,6 +42,19 @@ const jsonParser = bodyParser.json({
}); });
const jwtAuth = authenticateJwt(); const jwtAuth = authenticateJwt();
const optionalJwtAuth: RequestHandler = (req, res, next) => {
const authenticate = authenticateJwtWithCallback((error, user) => {
if (error) {
next(error);
return;
}
if (user) setCurrentUser(req, user);
next();
});
authenticate(req, res, next);
};
const downloadHandler: RequestHandler = wrapAsync(async (req, res) => const downloadHandler: RequestHandler = wrapAsync(async (req, res) =>
services.downloadFile(req, res), services.downloadFile(req, res),
@ -44,6 +66,31 @@ const presignHandler = async (
) => { ) => {
const log = getRequestLogger(req) || logger; const log = getRequestLogger(req) || logger;
const { urls } = req.body; const { urls } = req.body;
const currentUser = getCurrentUser(req);
const runtimeContext = getRuntimeContext(req);
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
currentUser,
runtimeContext,
urls,
});
if (authorization === 'authentication_required') {
return res.status(401).json(
services.createErrorResponse(
'Authentication or public presentation context is required',
'PRESIGN_AUTH_REQUIRED',
),
);
}
if (authorization === 'denied') {
return res.status(403).json(
services.createErrorResponse(
'Asset access denied',
'PRESIGN_ACCESS_DENIED',
),
);
}
// Validate paths for security (no traversal, no protocols) // Validate paths for security (no traversal, no protocols)
const unsafeUrls = urls.filter((url) => !services.isValidPath(url)); const unsafeUrls = urls.filter((url) => !services.isValidPath(url));
@ -105,6 +152,7 @@ router.post(
'/presign', '/presign',
jsonParser, jsonParser,
validateRequest(fileSchemas.presign), validateRequest(fileSchemas.presign),
optionalJwtAuth,
wrapAsync(presignHandler), wrapAsync(presignHandler),
); );

View File

@ -153,6 +153,14 @@ export default class AccessPolicy {
const project = await this.getProjectBySlug(projectSlug, options); const project = await this.getProjectBySlug(projectSlug, options);
if (!project) return false; if (!project) return false;
return this.canViewProductionProject(user, project, options);
}
static async canViewProductionProject(
user: AccessPolicyUser,
project: ProductionPresentationProject,
options: AccessPolicyOptions = {},
): Promise<boolean> {
if (project.production_presentation_visibility !== 'private') { if (project.production_presentation_visibility !== 'private') {
return true; return true;
} }

View File

@ -12,6 +12,7 @@ import {
import ValidationError from './notifications/errors/validation.ts'; import ValidationError from './notifications/errors/validation.ts';
import FileService from './file/index.ts'; import FileService from './file/index.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts';
import type { import type {
CurrentUser, CurrentUser,
FileCopyOperation, FileCopyOperation,
@ -29,24 +30,6 @@ import type {
RuntimeContext, RuntimeContext,
} from '../types/index.ts'; } from '../types/index.ts';
const ASSET_FIELDS = new Set([
'src',
'mediaUrl',
'imageUrl',
'videoUrl',
'audioUrl',
'transitionVideoUrl',
'reverseVideoUrl',
'thumbnail',
'storage_key',
'iconUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
]);
const buildWriteOptions = ( const buildWriteOptions = (
transaction: Transaction, transaction: Transaction,
runtimeContext: RuntimeContext | undefined, runtimeContext: RuntimeContext | undefined,
@ -99,7 +82,11 @@ const transformUiSchemaAssetPaths = (
return Object.fromEntries( return Object.fromEntries(
Object.entries(data).map(([key, value]) => { Object.entries(data).map(([key, value]) => {
if (ASSET_FIELDS.has(key) && typeof value === 'string' && value) { if (
UI_SCHEMA_ASSET_FIELDS.has(key) &&
typeof value === 'string' &&
value
) {
return [key, assetPathMap.get(value) || value]; return [key, assetPathMap.get(value) || value];
} }

View File

@ -0,0 +1,217 @@
import config from '../config.ts';
import RuntimeAssetAccessDBApi from '../db/api/runtime-asset-access.ts';
import type { AccessPolicyUser, RuntimeContext } from '../types/index.ts';
import { UI_SCHEMA_ASSET_FIELDS } from '../utils/ui-schema-assets.ts';
import AccessPolicy from './access-policy.ts';
type PresignAuthorization =
| 'allowed'
| 'authentication_required'
| 'denied';
interface AuthorizePresignRequestOptions {
currentUser: AccessPolicyUser;
runtimeContext: RuntimeContext | undefined;
urls: readonly string[];
}
interface CanPresignProductionAssetsOptions {
currentUser: AccessPolicyUser;
projectSlug: string;
urls: readonly string[];
}
interface PlainRecord {
[key: string]: unknown;
}
interface RecordWithPlainGetter {
get(options: { plain: true }): unknown;
}
function isPlainRecord(value: unknown): value is PlainRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function hasPlainGetter(value: unknown): value is RecordWithPlainGetter {
return isPlainRecord(value) && typeof value.get === 'function';
}
function toPlainRecord(value: unknown): PlainRecord {
const plainValue = hasPlainGetter(value) ? value.get({ plain: true }) : value;
return isPlainRecord(plainValue) ? plainValue : {};
}
function stripStoragePrefix(value: string): string {
const normalized = value.replace(/^\/+/, '');
const prefix = config.s3.prefix.replace(/^\/+|\/+$/g, '');
if (prefix && normalized.startsWith(`${prefix}/`)) {
return normalized.slice(prefix.length + 1);
}
return normalized;
}
function normalizeStorageReference(value: string): string | null {
const trimmed = value.trim();
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('data:')) {
return null;
}
try {
const parsed = new URL(trimmed, 'http://runtime.local');
const privateUrl = parsed.searchParams.get('privateUrl');
if (privateUrl) {
return stripStoragePrefix(privateUrl);
}
if (parsed.origin !== 'http://runtime.local') {
return stripStoragePrefix(decodeURIComponent(parsed.pathname));
}
} catch {
return stripStoragePrefix(trimmed.split(/[?#]/, 1)[0] ?? trimmed);
}
return stripStoragePrefix(trimmed.split(/[?#]/, 1)[0] ?? trimmed);
}
function collectStringReferences(value: unknown, references: Set<string>): void {
if (typeof value === 'string') {
const normalized = normalizeStorageReference(value);
if (normalized) references.add(normalized);
return;
}
if (Array.isArray(value)) {
for (const item of value) collectStringReferences(item, references);
return;
}
if (isPlainRecord(value)) {
for (const item of Object.values(value)) {
collectStringReferences(item, references);
}
}
}
function collectUiSchemaAssetReferences(
value: unknown,
references: Set<string>,
): void {
let parsedValue = value;
if (typeof parsedValue === 'string') {
try {
parsedValue = JSON.parse(parsedValue);
} catch {
return;
}
}
if (Array.isArray(parsedValue)) {
for (const item of parsedValue) {
collectUiSchemaAssetReferences(item, references);
}
return;
}
if (!isPlainRecord(parsedValue)) return;
for (const [key, item] of Object.entries(parsedValue)) {
if (UI_SCHEMA_ASSET_FIELDS.has(key) && typeof item === 'string') {
collectStringReferences(item, references);
} else if (typeof item === 'object' && item !== null) {
collectUiSchemaAssetReferences(item, references);
}
}
}
function collectRecordFields(
rows: readonly unknown[],
fields: readonly string[],
references: Set<string>,
): void {
for (const row of rows) {
const record = toPlainRecord(row);
for (const field of fields) {
collectStringReferences(record[field], references);
}
}
}
export default class RuntimeAssetAccessService {
static async authorizePresignRequest(
options: AuthorizePresignRequestOptions,
): Promise<PresignAuthorization> {
const { currentUser, runtimeContext, urls } = options;
if (AccessPolicy.canUseAdminApi(currentUser)) return 'allowed';
const projectSlug = AccessPolicy.normalizeSlug(
runtimeContext?.headerProjectSlug,
);
if (
runtimeContext?.headerEnvironment !== 'production' ||
!projectSlug
) {
return 'authentication_required';
}
const canPresign = await this.canPresignProductionAssets({
currentUser,
projectSlug,
urls,
});
return canPresign ? 'allowed' : 'denied';
}
private static async canPresignProductionAssets(
options: CanPresignProductionAssetsOptions,
): Promise<boolean> {
const { currentUser, projectSlug, urls } = options;
const project = await AccessPolicy.getProjectBySlug(projectSlug);
if (!project) return false;
const canView = await AccessPolicy.canViewProductionProject(
currentUser,
project,
);
if (!canView) return false;
const { projectRow, assets, variants, pages, audioTracks } =
await RuntimeAssetAccessDBApi.findPresentationAssetReferences({
projectId: project.id,
});
const references = new Set<string>();
collectRecordFields(
projectRow ? [projectRow] : [],
['logo_url', 'favicon_url', 'og_image_url'],
references,
);
collectRecordFields(assets, ['storage_key', 'cdn_url'], references);
collectRecordFields(variants, ['storage_key', 'cdn_url'], references);
collectRecordFields(
pages,
[
'background_image_url',
'background_video_url',
'background_audio_url',
],
references,
);
for (const page of pages) {
collectUiSchemaAssetReferences(
toPlainRecord(page).ui_schema_json,
references,
);
}
collectRecordFields(audioTracks, ['source_key', 'url'], references);
return urls.every((url) => {
const normalized = normalizeStorageReference(url);
return normalized !== null && references.has(normalized);
});
}
}
export { collectUiSchemaAssetReferences, normalizeStorageReference };

View File

@ -7,10 +7,7 @@ export type ExpressRouter = Router;
export interface HealthResponse { export interface HealthResponse {
status: 'ok' | 'degraded'; status: 'ok' | 'degraded';
timestamp: string; timestamp: string;
uptime: number;
environment: string;
database?: 'connected' | 'disconnected'; database?: 'connected' | 'disconnected';
databaseError?: string;
} }
export interface RuntimeJwtVerifyHandler { export interface RuntimeJwtVerifyHandler {

View File

@ -1,6 +1,8 @@
import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js'; import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js';
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
import type { NodeEnvironment } from './env.ts';
export interface BackendEmailConfig extends Omit< export interface BackendEmailConfig extends Omit<
SMTPTransport.Options, SMTPTransport.Options,
'auth' 'auth'
@ -77,22 +79,12 @@ export interface BackendConfig {
user?: string; user?: string;
}; };
server: { server: {
env: string; env: NodeEnvironment;
port: number; port: number;
swaggerServerUrl: string; swaggerServerUrl: string;
}; };
remote: string;
port: string;
host: string;
hostUI: string;
portUI: string;
portUIProd: string;
swaggerUI: string;
swaggerPort: string;
swaggerUrl: string;
apiUrl: string; apiUrl: string;
uiUrl: string; uiUrl: string;
backUrl: string;
uploadDir: string; uploadDir: string;
s3CacheDir: string; s3CacheDir: string;
s3CacheEnabled: boolean; s3CacheEnabled: boolean;

View File

@ -4,7 +4,7 @@ export type NodeEnvironment =
export interface ValidatedEnvironment { export interface ValidatedEnvironment {
NODE_ENV: NodeEnvironment; NODE_ENV: NodeEnvironment;
PORT: number; PORT: number;
NEXT_PUBLIC_BACK_API: string; UI_URL: string;
DB_HOST: string; DB_HOST: string;
DB_PORT: number; DB_PORT: number;
DB_NAME: string; DB_NAME: string;

51
backend/src/utils/cors.ts Normal file
View File

@ -0,0 +1,51 @@
import type { CorsOptions } from 'cors';
import type { NodeEnvironment } from '../types/index.ts';
const LOCAL_DEVELOPMENT_ORIGINS = new Set([
'http://localhost:3001',
'http://127.0.0.1:3001',
]);
function getOrigin(value: string): string | null {
if (!value) return null;
try {
return new URL(value).origin;
} catch {
return null;
}
}
function createCorsOptions(
uiUrl: string,
nodeEnv: NodeEnvironment,
): CorsOptions {
const allowedOrigins = new Set<string>();
const configuredUiOrigin = getOrigin(uiUrl);
if (configuredUiOrigin) {
allowedOrigins.add(configuredUiOrigin);
}
if (nodeEnv !== 'production') {
for (const origin of LOCAL_DEVELOPMENT_ORIGINS) {
allowedOrigins.add(origin);
}
}
return {
credentials: false,
origin(origin, callback) {
if (!origin || allowedOrigins.has(origin)) {
callback(null, true);
return;
}
// The request may still proceed, but browsers receive no cross-origin
// read permission for untrusted origins.
callback(null, false);
},
};
}
export { createCorsOptions };

View File

@ -12,7 +12,14 @@ const envSchema = Joi.object({
.default('development'), .default('development'),
PORT: Joi.number().default(8080), PORT: Joi.number().default(8080),
NEXT_PUBLIC_BACK_API: Joi.string().uri().allow('').default(''), UI_URL: Joi.string()
.uri({ scheme: ['http', 'https'] })
.allow('')
.default('')
.when('NODE_ENV', {
is: 'production',
then: Joi.string().min(1).required(),
}),
DB_HOST: Joi.string().default('localhost'), DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(5432), DB_PORT: Joi.number().default(5432),
@ -20,12 +27,10 @@ const envSchema = Joi.object({
DB_USER: Joi.string().default('postgres'), DB_USER: Joi.string().default('postgres'),
DB_PASS: Joi.string().allow('').default(''), DB_PASS: Joi.string().allow('').default(''),
SECRET_KEY: Joi.string() SECRET_KEY: Joi.string().min(32).required(),
.min(16)
.default('88dbeaf8-e906-405e-9e41-c3baadeda5c6'),
ADMIN_PASS: Joi.string().default('88dbeaf8'), ADMIN_PASS: Joi.string().allow('').default(''),
USER_PASS: Joi.string().default('c3baadeda5c6'), USER_PASS: Joi.string().allow('').default(''),
ADMIN_EMAIL: Joi.string().email().default('admin@flatlogic.com'), ADMIN_EMAIL: Joi.string().email().default('admin@flatlogic.com'),
GOOGLE_CLIENT_ID: Joi.string().allow('').default(''), GOOGLE_CLIENT_ID: Joi.string().allow('').default(''),
@ -156,7 +161,7 @@ function toValidatedEnvironment(
return { return {
NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development', NODE_ENV: isNodeEnvironment(nodeEnv) ? nodeEnv : 'development',
PORT: readNumber(values, 'PORT', 8080), PORT: readNumber(values, 'PORT', 8080),
NEXT_PUBLIC_BACK_API: readString(values, 'NEXT_PUBLIC_BACK_API', ''), UI_URL: readString(values, 'UI_URL', ''),
DB_HOST: readString(values, 'DB_HOST', 'localhost'), DB_HOST: readString(values, 'DB_HOST', 'localhost'),
DB_PORT: readNumber(values, 'DB_PORT', 5432), DB_PORT: readNumber(values, 'DB_PORT', 5432),
DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'), DB_NAME: readString(values, 'DB_NAME', 'db_tour_builder_platform'),
@ -165,10 +170,10 @@ function toValidatedEnvironment(
SECRET_KEY: readString( SECRET_KEY: readString(
values, values,
'SECRET_KEY', 'SECRET_KEY',
'88dbeaf8-e906-405e-9e41-c3baadeda5c6', '',
), ),
ADMIN_PASS: readString(values, 'ADMIN_PASS', '88dbeaf8'), ADMIN_PASS: readString(values, 'ADMIN_PASS', ''),
USER_PASS: readString(values, 'USER_PASS', 'c3baadeda5c6'), USER_PASS: readString(values, 'USER_PASS', ''),
ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'), ADMIN_EMAIL: readString(values, 'ADMIN_EMAIL', 'admin@flatlogic.com'),
GOOGLE_CLIENT_ID: readString(values, 'GOOGLE_CLIENT_ID', ''), GOOGLE_CLIENT_ID: readString(values, 'GOOGLE_CLIENT_ID', ''),
GOOGLE_CLIENT_SECRET: readString(values, 'GOOGLE_CLIENT_SECRET', ''), GOOGLE_CLIENT_SECRET: readString(values, 'GOOGLE_CLIENT_SECRET', ''),
@ -267,11 +272,7 @@ function validateEnv(): ValidatedEnvironment {
); );
logger.error({ errors: messages }, 'Environment validation failed'); logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') { throw new Error(`Invalid backend environment:\n${messages.join('\n')}`);
process.exit(1);
} else {
logger.warn('Continuing with default values in non-production mode');
}
} }
const resultValue: unknown = result.value; const resultValue: unknown = result.value;

View File

@ -0,0 +1,26 @@
const UI_SCHEMA_ASSET_FIELDS: ReadonlySet<string> = new Set([
'src',
'url',
'poster',
'thumbnail',
'storage_key',
'iconUrl',
'imageUrl',
'mediaUrl',
'videoUrl',
'audioUrl',
'hoverAudioUrl',
'clickAudioUrl',
'transitionVideoUrl',
'reverseVideoUrl',
'backgroundImageUrl',
'carouselPrevIconUrl',
'carouselNextIconUrl',
'galleryHeaderImageUrl',
'galleryCarouselPrevIconUrl',
'galleryCarouselNextIconUrl',
'galleryCarouselBackIconUrl',
'infoPanelHeaderImageUrl',
]);
export { UI_SCHEMA_ASSET_FIELDS };

View File

@ -75,3 +75,31 @@ void test('platform-wide roles are explicit', () => {
false, false,
); );
}); });
void test('production project access reuses an already-loaded public project', async () => {
const canView = await AccessPolicy.canViewProductionProject(undefined, {
id: 'project-1',
name: 'Public tour',
slug: 'public-tour',
production_presentation_visibility: 'public',
});
assert.equal(canView, true);
});
void test('production project access allows staff without another project lookup', async () => {
const canView = await AccessPolicy.canViewProductionProject(
{
id: 'staff-1',
app_role_permissions: ['READ_PROJECTS'],
},
{
id: 'project-1',
name: 'Private tour',
slug: 'private-tour',
production_presentation_visibility: 'private',
},
);
assert.equal(canView, true);
});

View File

@ -59,7 +59,10 @@ void test('normalizeLoggedError uses string reasons as messages', () => {
}); });
void test('envSchema normalizes file storage provider override', () => { void test('envSchema normalizes file storage provider override', () => {
const result = envSchema.validate({ FILE_STORAGE_PROVIDER: ' S3 ' }); const result = envSchema.validate({
FILE_STORAGE_PROVIDER: ' S3 ',
SECRET_KEY: 'test-secret-key-with-at-least-32-characters',
});
const providerDescriptor = const providerDescriptor =
typeof result.value === 'object' && result.value !== null typeof result.value === 'object' && result.value !== null
? Object.getOwnPropertyDescriptor(result.value, 'FILE_STORAGE_PROVIDER') ? Object.getOwnPropertyDescriptor(result.value, 'FILE_STORAGE_PROVIDER')
@ -69,6 +72,22 @@ void test('envSchema normalizes file storage provider override', () => {
assert.equal(providerDescriptor?.value, 's3'); assert.equal(providerDescriptor?.value, 's3');
}); });
void test('envSchema requires the canonical UI origin in production', () => {
const secret = 'test-secret-key-with-at-least-32-characters';
const missingUiUrl = envSchema.validate({
NODE_ENV: 'production',
SECRET_KEY: secret,
});
const configuredUiUrl = envSchema.validate({
NODE_ENV: 'production',
SECRET_KEY: secret,
UI_URL: 'https://tbp.flatlogic.app',
});
assert.ok(missingUiUrl.error);
assert.equal(configuredUiUrl.error, undefined);
});
void test('CircuitBreaker can ignore non-breaker failures', async () => { void test('CircuitBreaker can ignore non-breaker failures', async () => {
const breaker = new CircuitBreaker({ const breaker = new CircuitBreaker({
name: 'test-breaker', name: 'test-breaker',

View File

@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import RuntimeAssetAccessService, {
collectUiSchemaAssetReferences,
normalizeStorageReference,
} from '../src/services/runtime-asset-access.ts';
import type { NodeEnvironment } from '../src/types/index.ts';
import { createCorsOptions } from '../src/utils/cors.ts';
async function isOriginAllowed(
origin: string | undefined,
nodeEnv: NodeEnvironment = 'dev_stage',
): Promise<boolean> {
const options = createCorsOptions('http://localhost:3001', nodeEnv);
const originHandler = options.origin;
assert.equal(typeof originHandler, 'function');
if (typeof originHandler !== 'function') return false;
return new Promise<boolean>((resolve, reject) => {
originHandler(origin, (error, allowed) => {
if (error) {
reject(error);
return;
}
resolve(allowed === true);
});
});
}
void test('CORS allows the configured UI and rejects an untrusted origin', async () => {
assert.equal(await isOriginAllowed('http://localhost:3001'), true);
assert.equal(await isOriginAllowed('https://attacker.example'), false);
assert.equal(await isOriginAllowed(undefined), true);
});
void test('production CORS does not enable local development origins implicitly', async () => {
assert.equal(
await isOriginAllowed('http://127.0.0.1:3001', 'production'),
false,
);
});
void test('runtime asset references normalize relative and proxied storage keys', () => {
assert.equal(
normalizeStorageReference('/assets/project/image.webp?cache=1'),
'assets/project/image.webp',
);
assert.equal(
normalizeStorageReference(
'/api/file/download?privateUrl=assets%2Fproject%2Fimage.webp',
),
'assets/project/image.webp',
);
assert.equal(normalizeStorageReference('data:image/png;base64,abc'), null);
});
void test('runtime page schemas collect media fields without treating labels as storage keys', () => {
const references = new Set<string>();
collectUiSchemaAssetReferences(
{
label: 'assets/not-a-real-reference.jpg',
content: {
imageUrl: 'assets/project/image.webp',
transitionVideoUrl: 'assets/project/transition.mp4',
},
},
references,
);
assert.deepEqual([...references].sort(), [
'assets/project/image.webp',
'assets/project/transition.mp4',
]);
});
void test('presigning without staff access or public runtime context requires authentication', async () => {
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
currentUser: undefined,
runtimeContext: undefined,
urls: ['assets/project/image.webp'],
});
assert.equal(authorization, 'authentication_required');
});
void test('staff permissions authorize presigning without public runtime context', async () => {
const authorization =
await RuntimeAssetAccessService.authorizePresignRequest({
currentUser: {
id: 'staff-user',
app_role_permissions: ['READ_ASSETS'],
},
runtimeContext: undefined,
urls: ['assets/project/image.webp'],
});
assert.equal(authorization, 'allowed');
});

View File

@ -241,7 +241,7 @@ function requestLogger(req, res, next) {
**Source:** `backend/src/index.ts` **Source:** `backend/src/index.ts`
```javascript ```javascript
app.enable('trust proxy'); // Extract real IP behind proxies app.set('trust proxy', 'loopback'); // Trust only same-VM reverse proxies
app.use(requestLogger); // Apply to all routes app.use(requestLogger); // Apply to all routes
``` ```

View File

@ -50,7 +50,9 @@ In **production** runtime mode only, certain GET endpoints allow unauthenticated
- `GET /api/global-ui-control-defaults` - `GET /api/global-ui-control-defaults`
- `GET /api/project-ui-control-settings/project/:projectId/env/production` - `GET /api/project-ui-control-settings/project/:projectId/env/production`
**Note:** The `X-Runtime-Environment` header must be set to `production` for public access. Stage environment (`stage`) requires JWT authentication as it serves as a workspace for review. **Note:** Anonymous production reads require both
`X-Runtime-Environment: production` and `X-Runtime-Project-Slug`. The slug must
identify a public production presentation. Stage requires JWT authentication.
--- ---
@ -362,8 +364,6 @@ Health check endpoint.
{ {
"status": "ok", "status": "ok",
"timestamp": "2024-01-01T00:00:00.000Z", "timestamp": "2024-01-01T00:00:00.000Z",
"uptime": 3600,
"environment": "production",
"database": "connected" "database": "connected"
} }
``` ```
@ -484,7 +484,10 @@ Download file.
Generate presigned URLs for batch asset downloads. For S3 storage, returns direct S3 signed URLs for client-side downloads (bypassing the backend). For other storage providers, returns backend proxy URLs. Generate presigned URLs for batch asset downloads. For S3 storage, returns direct S3 signed URLs for client-side downloads (bypassing the backend). For other storage providers, returns backend proxy URLs.
**Auth:** Not required (public endpoint for runtime asset preloading) **Auth:** Internal staff may use a bearer token. Anonymous and Public-user
requests must send `X-Runtime-Environment: production` and
`X-Runtime-Project-Slug`; each requested key must belong to that accessible
presentation.
**Request:** **Request:**
```json ```json
@ -500,6 +503,8 @@ Generate presigned URLs for batch asset downloads. For S3 storage, returns direc
**Limits:** **Limits:**
- Maximum 50 URLs per request - Maximum 50 URLs per request
- All URLs must be non-empty strings - All URLs must be non-empty strings
- Requests without staff access or production runtime context return `401`
- Inaccessible presentations or unrelated storage keys return `403`
**Response:** **Response:**
```json ```json

View File

@ -201,6 +201,13 @@ const resolveUrlWithBlob = useCallback(
For S3 storage, the preloader fetches presigned URLs before downloading assets for direct S3 access: For S3 storage, the preloader fetches presigned URLs before downloading assets for direct S3 access:
Runtime presign batches carry `X-Runtime-Environment` and
`X-Runtime-Project-Slug`. Anonymous and Public-user requests are accepted only
for an accessible production presentation, and each requested storage key must
be referenced by that project's assets, variants, branding, production pages,
page schema, or production audio tracks. Authenticated internal staff retain
all-project presign access for constructor and administration workflows.
```typescript ```typescript
// In usePreloadOrchestrator.ts // In usePreloadOrchestrator.ts
// 1. Collect storage paths that need presigning // 1. Collect storage paths that need presigning
@ -246,6 +253,7 @@ if (isPresignedUrl(item.url)) {
| `disablePresignedUrls()` | Globally disable presigned URLs (fallback to proxy) | | `disablePresignedUrls()` | Globally disable presigned URLs (fallback to proxy) |
| `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled | | `arePresignedUrlsDisabled()` | Check if presigned URLs are disabled |
| `clearPresignedUrlCache()` | Clear all cached presigned URLs | | `clearPresignedUrlCache()` | Clear all cached presigned URLs |
| `setPresignRuntimeContext()` | Scope public runtime presign batches to the active presentation |
**Key Functions (usePreloadOrchestrator):** **Key Functions (usePreloadOrchestrator):**
| Function | Purpose | | Function | Purpose |

View File

@ -118,19 +118,21 @@ passport.use(new MicrosoftStrategy({
| GET | `/auth/signin/microsoft` | No | No | Initiate Microsoft OAuth | | GET | `/auth/signin/microsoft` | No | No | Initiate Microsoft OAuth |
| GET | `/auth/signin/microsoft/callback` | No | No | Microsoft OAuth callback | | GET | `/auth/signin/microsoft/callback` | No | No | Microsoft OAuth callback |
### File Endpoints (Public) ### File Endpoints
| Method | Endpoint | Auth | Description | | Method | Endpoint | Auth | Description |
|--------|----------|------|-------------| |--------|----------|------|-------------|
| GET | `/file/download` | No | Download file (backend proxy for local/GCloud) | | GET | `/file/download` | No | Download file (backend proxy for local/GCloud) |
| POST | `/file/presign` | No | Generate presigned URLs for S3 direct downloads | | POST | `/file/presign` | Optional JWT or production runtime context | Generate presentation-scoped presigned URLs for S3 direct downloads |
| POST | `/file/upload/:table/:field` | JWT | Legacy single-file upload | | POST | `/file/upload/:table/:field` | JWT | Legacy single-file upload |
| POST | `/file/upload-sessions/init` | JWT | Initialize chunked upload | | POST | `/file/upload-sessions/init` | JWT | Initialize chunked upload |
| PUT | `/file/upload-sessions/:id/chunks/:idx` | JWT | Upload chunk | | PUT | `/file/upload-sessions/:id/chunks/:idx` | JWT | Upload chunk |
| GET | `/file/upload-sessions/:id` | JWT | Check upload session status | | GET | `/file/upload-sessions/:id` | JWT | Check upload session status |
| POST | `/file/upload-sessions/:id/finalize` | JWT | Finalize chunked upload | | POST | `/file/upload-sessions/:id/finalize` | JWT | Finalize chunked upload |
**Note:** The download and presign endpoints are intentionally public to support runtime asset preloading without authentication. Access control for assets should be implemented at the storage level (S3 bucket policies) if needed. **Note:** Downloads remain public for presentation playback. Presigning is
available to internal staff, or to an accessible production presentation when
every requested storage key belongs to that presentation.
### Endpoint Details ### Endpoint Details
@ -256,6 +258,15 @@ const authLimiter = createRateLimiter({
- Returns 429 status with JSON response when exceeded - Returns 429 status with JSON response when exceeded
- Automatic cleanup of expired entries every 5 minutes - Automatic cleanup of expired entries every 5 minutes
- Skips rate limiting in development for localhost - Skips rate limiting in development for localhost
- Express trusts only loopback reverse proxies. On the standard VM, the local
tunnel's forwarded chain is retained and Express selects the first untrusted
hop from the right, ignoring attacker-controlled values on the left. Direct
Nginx and DNS-only Apache configurations replace supplied chains with their
socket client address.
Bearer tokens are stored in `sessionStorage` only. Older `localStorage` token
and user entries are removed during login and logout so credentials do not
survive closing the browser.
## User Model ## User Model
@ -376,13 +387,10 @@ On successful login:
```typescript ```typescript
builder.addCase(loginUser.fulfilled, (state, action) => { builder.addCase(loginUser.fulfilled, (state, action) => {
const token = action.payload; const token = action.payload;
const user = jwt.decode(token); const user = decodeAuthToken(token);
// Store in both storages // Session-scoped storage also removes legacy localStorage credentials
sessionStorage.setItem('token', token); storeAuthSession(token, user);
sessionStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
// Set default header // Set default header
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
@ -392,10 +400,7 @@ builder.addCase(loginUser.fulfilled, (state, action) => {
On logout (reducer action in authSlice): On logout (reducer action in authSlice):
```typescript ```typescript
logoutUser: (state) => { logoutUser: (state) => {
sessionStorage.removeItem('token'); clearAuthSession();
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
axios.defaults.headers.common['Authorization'] = ''; // Set to empty in reducer axios.defaults.headers.common['Authorization'] = ''; // Set to empty in reducer
state.currentUser = null; state.currentUser = null;
state.token = ''; state.token = '';
@ -410,7 +415,7 @@ logoutUser: (state) => {
```typescript ```typescript
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = sessionStorage.getItem('token') || localStorage.getItem('token'); const token = getStoredAuthToken();
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@ -445,10 +450,7 @@ axios.interceptors.response.use(
if (status === 401 && !isLoginRequest) { if (status === 401 && !isLoginRequest) {
// Clear stored tokens // Clear stored tokens
sessionStorage.removeItem('token'); clearAuthSession();
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
delete axios.defaults.headers.common['Authorization']; delete axios.defaults.headers.common['Authorization'];
// Redirect to login if not already there // Redirect to login if not already there
@ -484,7 +486,7 @@ const isPresignedS3Url = (url: string): boolean => {
- Email verified (if email configured) - Email verified (if email configured)
- Password matches (bcrypt.compare) - Password matches (bcrypt.compare)
5. Return JWT token (6h expiration) 5. Return JWT token (6h expiration)
6. Frontend stores token in sessionStorage + localStorage 6. Frontend stores the token for the current browser tab/session only
7. Set axios Authorization header 7. Set axios Authorization header
8. dispatch(findMe()) → GET /auth/me 8. dispatch(findMe()) → GET /auth/me
9. Redirect to /dashboard 9. Redirect to /dashboard
@ -566,7 +568,7 @@ const isPresignedS3Url = (url: string): boolean => {
| Expiration | 6 hours | | Expiration | 6 hours |
| Secret | Environment variable `SECRET_KEY` | | Secret | Environment variable `SECRET_KEY` |
| Transmission | Bearer token in Authorization header | | Transmission | Bearer token in Authorization header |
| Storage | sessionStorage + localStorage | | Storage | `sessionStorage` through `lib/authStorage.ts` |
### Verification Tokens ### Verification Tokens
@ -597,7 +599,7 @@ app.use('/api/permissions', jwtAuth, permissionsRoutes);
**Runtime routes with optional auth (environment-based):** **Runtime routes with optional auth (environment-based):**
The frontend sends `X-Runtime-Environment` header to indicate the environment context: The frontend sends `X-Runtime-Environment` header to indicate the environment context:
- `production` - Public tour pages (no auth required for GET requests) - `production` - Known public slugs allow anonymous GET requests; private slugs require access
- `stage` - Preview environment (requires authentication) - `stage` - Preview environment (requires authentication)
- `dev` - Constructor editing (requires authentication) - `dev` - Constructor editing (requires authentication)
@ -607,16 +609,16 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
const headerProjectSlug = req.runtimeContext?.headerProjectSlug; const headerProjectSlug = req.runtimeContext?.headerProjectSlug;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
// Only production is public. Stage requires authentication (workspace for review). // Production reads require a known project slug. Public projects continue
const isPublicEnvironment = headerEnvironment === 'production'; // anonymously; private projects require JWT plus staff access or a viewer grant.
return authorizeRuntimeRead({
if (isPublicEnvironment && isReadOnlyRequest && !isPrivateProductionPresentation(headerProjectSlug)) { headerEnvironment,
req.isRuntimePublicRequest = true; // Allow public read access headerProjectSlug,
return next(); isReadOnlyRequest,
} req,
// Private production presentations require JWT + staff permission res,
// or a production_presentation_access grant. next,
return jwtAuth(req, res, next); });
}; };
``` ```
@ -629,11 +631,10 @@ database separately from broad RBAC permissions. See
- `tour_pages` - Page content including `ui_schema_json` - `tour_pages` - Page content including `ui_schema_json`
- `project_audio_tracks` - Background audio tracks - `project_audio_tracks` - Background audio tracks
**File endpoints (no auth required):** **Runtime file endpoints:**
```javascript ```javascript
// No authentication - used for runtime asset loading
GET /api/file/download?privateUrl={path} // Backend proxy for local/GCloud storage GET /api/file/download?privateUrl={path} // Backend proxy for local/GCloud storage
POST /api/file/presign // Generate presigned URLs for S3 direct download POST /api/file/presign // Staff JWT or accessible production context
``` ```
## Configuration ## Configuration
@ -689,6 +690,16 @@ EMAIL_PASS=your_smtp_password
NEXT_PUBLIC_BACK_API=http://localhost:3000/api NEXT_PUBLIC_BACK_API=http://localhost:3000/api
``` ```
**Canonical backend-to-frontend URL:**
```bash
UI_URL=https://tbp.flatlogic.app
```
`UI_URL` is validated by the backend and normalized to an origin. It supplies
OAuth redirects, the same-origin `/api` URL, and the backend CORS allowlist.
It is required when `NODE_ENV=production`; other environments default to
`http://localhost:3001` when it is absent.
## Error Handling ## Error Handling
### Validation Errors (400) ### Validation Errors (400)
@ -759,6 +770,6 @@ res.status(429).send({
- Ensure OAuth scopes are properly configured - Ensure OAuth scopes are properly configured
**Token not persisting after refresh:** **Token not persisting after refresh:**
- Check both sessionStorage and localStorage - Check `sessionStorage`; tokens intentionally do not survive closing the browser
- Verify axios interceptors are properly attached - Verify axios interceptors are properly attached
- Check for errors in browser console - Check for errors in browser console

View File

@ -127,6 +127,10 @@ Use this HTTP virtual host as the starting point:
ProxyPreserveHost On ProxyPreserveHost On
ProxyRequests Off ProxyRequests Off
# This example assumes the documented DNS-only setup, where Apache receives
# the visitor connection directly. Discard any supplied forwarding chain.
RequestHeader unset X-Forwarded-For
RewriteEngine On RewriteEngine On
RewriteRule ^/api(/.*)?$ http://127.0.0.1:3000$0 [P,L] RewriteRule ^/api(/.*)?$ http://127.0.0.1:3000$0 [P,L]

View File

@ -152,6 +152,49 @@ This file contains environment variables and may contain secrets. Do not paste
it into public tools or tickets without redacting tokens, DB passwords, SMTP it into public tools or tickets without redacting tokens, DB passwords, SMTP
credentials, API keys, and tunnel credentials. credentials, API keys, and tunnel credentials.
The repository intentionally delivers `backend/.env` to the VM. Repository
access must therefore remain limited to trusted operators. Treat loss of a
repository clone or expansion of repository access as a credential-rotation
event.
Set the canonical frontend origin in the tracked backend environment:
```bash
UI_URL=https://tbp.flatlogic.app
```
The backend uses this value for OAuth redirects, its same-origin `/api` URL,
and its direct CORS allowlist. A true `NODE_ENV=production` startup requires
`UI_URL`; the standard VM uses `dev_stage` but still sets it explicitly.
`ADMIN_PASS` and `USER_PASS` have no built-in fallback. Existing VMs do not
need them after the seed users exist. A fresh database must receive both values
before its first seed run; otherwise seeding stops instead of creating accounts
with known default passwords.
The bundled Nginx configuration is used by the Docker development image, not
by the standard VM. It replaces client-supplied `X-Forwarded-For` values with
`$remote_addr`, which is correct when Nginx is the public entrypoint.
On the standard VM, the Cloudflare/Flatlogic tunnel connects to Apache from
`127.0.0.1`, and Apache proxies to the backend over loopback. Keep the forwarded
chain on this path. Do not add `RequestHeader unset X-Forwarded-For` to the
standard Flatlogic virtual host: doing so would replace the visitor chain with
the local tunnel address and place all visitors in one rate-limit bucket.
Express trusts only the loopback proxy hop. It therefore ignores attacker-
controlled addresses on the left of a forwarded chain and uses the first
untrusted address from the right. An external check on 2026-07-22 also found
that the VM origin IP did not accept direct port 80 connections, so clients
could not bypass the tunnel and send a single forged forwarding value directly
to Apache.
After deployment, make two failed login requests through the public hostname
with different client-supplied `X-Forwarded-For` values. The same rate-limit
bucket must decrement from `9` to `8`. If both responses report `9`, the bypass
still exists. Repeat the origin-port reachability check if firewall or tunnel
configuration changes.
## Health Checks ## Health Checks
Use these checks after a deploy or incident: Use these checks after a deploy or incident:
@ -174,6 +217,11 @@ Expected healthy responses:
- `http://tbp.flatlogic.app` returns `200 OK`. - `http://tbp.flatlogic.app` returns `200 OK`.
- PM2 shows all four apps `online`. - PM2 shows all four apps `online`.
Before building a new immutable frontend release, keep enough free disk space
for dependencies and build output. During the 2026-07-22 assessment, the root
filesystem was reduced from 94% to 72% usage by running `yarn cache clean` from
`/tmp`. The removed Yarn v6 cache was not used by this npm-managed repository.
## Recovering From Apache `503 Service Unavailable` ## Recovering From Apache `503 Service Unavailable`
If Apache returns: If Apache returns:

View File

@ -50,6 +50,12 @@ The service determines:
For public production slugs, read-only runtime requests stay public. For public production slugs, read-only runtime requests stay public.
Every anonymous production runtime request must include both
`X-Runtime-Environment: production` and a non-empty
`X-Runtime-Project-Slug`. Missing slugs return `400` and unknown slugs return
`404`; public runtime queries are never allowed to fall back to an unscoped
all-project list.
For private production slugs: For private production slugs:
- anonymous `GET` requests return `401` - anonymous `GET` requests return `401`
@ -61,6 +67,12 @@ For private production slugs:
- authorized users are marked as `req.isRuntimePublicRequest = true` so runtime - authorized users are marked as `req.isRuntimePublicRequest = true` so runtime
routes return only runtime-safe entity fields routes return only runtime-safe entity fields
Anonymous and Public-user presign requests use the same project slug and
production access decision. Requested storage keys are checked against project
assets, asset variants, production pages, page schemas, project branding, and
production audio tracks before URLs are signed. Internal staff with admin API
permissions retain all-project presign access.
Protected runtime data includes: Protected runtime data includes:
- `/api/projects` - `/api/projects`

View File

@ -337,7 +337,7 @@ class GenericDBApi {
```javascript ```javascript
// Applied in order // Applied in order
app.use(helmet()); // Security headers app.use(helmet()); // Security headers
app.use(cors({ origin: true })); // CORS app.use(cors(createCorsOptions(config.uiUrl, config.server.env)));
app.use(requestLogger); // Request logging app.use(requestLogger); // Request logging
app.use(runtimeContext); // Admin/stage/production detection app.use(runtimeContext); // Admin/stage/production detection
app.use(bodyParser.json()); // JSON parsing app.use(bodyParser.json()); // JSON parsing
@ -484,7 +484,7 @@ The platform supports direct asset downloads from S3 for better performance:
// Response: { presignedUrls: { "assets/image1.jpg": "https://s3...", ... } } // Response: { presignedUrls: { "assets/image1.jpg": "https://s3...", ... } }
// - Max 50 URLs per request // - Max 50 URLs per request
// - 1-hour expiry // - 1-hour expiry
// - Public endpoint (no auth required for runtime) // - Staff JWT or accessible production runtime context required
``` ```
Frontend preloading uses presigned URLs: Frontend preloading uses presigned URLs:

View File

@ -85,17 +85,18 @@ Publishing Flow:
### Authentication Model ### Authentication Model
The API uses **URL-path-based public access** for runtime presentations: The project ID in the URL identifies the presentation for access checks:
| Endpoint | Method | Environment | Auth Required | | Endpoint | Method | Environment | Auth Required |
|----------|--------|-------------|---------------| |----------|--------|-------------|---------------|
| `/project/:id/env/production` | GET | production | **No** (public) | | `/project/:id/env/production` | GET | production | No for public projects; JWT for private projects |
| `/project/:id/env/dev` | GET | dev | Yes | | `/project/:id/env/dev` | GET | dev | Yes |
| `/project/:id/env/stage` | GET | stage | Yes | | `/project/:id/env/stage` | GET | stage | Yes |
| `/project/:id/env/*` | PUT/DELETE | any | Yes | | `/project/:id/env/*` | PUT/DELETE | any | Yes |
| Standard CRUD (`/`, `/:id`) | all | n/a | Yes | | Standard CRUD (`/`, `/:id`) | all | n/a | Yes |
This allows public presentations (`/p/[slug]`) to fetch production transition settings without authentication, while protecting dev/stage environments and write operations. Public presentations can fetch production settings anonymously. Private
presentations require a staff permission or an explicit viewer grant.
Write operations use the `PAGE_ELEMENTS` permission family because transition Write operations use the `PAGE_ELEMENTS` permission family because transition
defaults are authored as part of the tour page/element editing surface. Global defaults are authored as part of the tour page/element editing surface. Global
@ -108,7 +109,7 @@ rather than `DELETE_PAGE_ELEMENTS`.
```http ```http
GET /api/project-transition-settings/project/:projectId/env/:environment GET /api/project-transition-settings/project/:projectId/env/:environment
# Production: No auth required (public) # Production: anonymous for public projects; bearer token for private projects
# Dev/Stage: Authorization: Bearer {token} # Dev/Stage: Authorization: Bearer {token}
``` ```

View File

@ -0,0 +1,133 @@
# Security Assessment - 2026-07-22
## Scope
This assessment covers the local `dev_stage` backend and Next.js frontend,
source configuration, production dependencies, runtime presentation access,
authentication rate limiting, CORS, browser security headers, and asset
presigning. Testing was non-destructive and did not include load testing or
external infrastructure scanning.
The browser/DevTools connector was unavailable during the assessment, so the
browser checks were performed through HTTP responses, source inspection, unit
tests, typechecks, lint, and production builds.
## Current Result
The confirmed private-presentation disclosure is fixed. Anonymous production
runtime reads now require a project slug, unknown slugs return `404`, and
private slugs require authentication. Anonymous presigning also requires a
valid production presentation context and every requested storage key must
belong to that presentation.
Current application risk is **moderate**. `npm audit --omit=dev` reports no
known production dependency advisories. Accepted operational risks include
credential delivery through the tracked backend `.env` file and Cloudflare's
public wildcard CORS response headers. Bearer tokens remain browser-readable
during an active tab session, but are no longer kept across browser sessions.
The new response policy further reduces that exposure.
## Findings and Current Status
| Finding | Resolution | Verification |
|---|---|---|
| Missing runtime slug exposed private production pages | Public runtime reads require a normalized slug before project visibility is evaluated | Missing slug `400`; unknown slug `404`; private slug `401` |
| Public presign accepted arbitrary storage keys | Non-staff calls require production runtime context, presentation access, and project-owned asset references | No context `401`; private anonymous context `403` |
| Forwarded IP spoofing bypassed rate limits | Express now trusts only the loopback proxy hop, so attacker-controlled addresses on the left of the tunnel's forwarded chain are ignored | Live old build gave separate buckets (`9`, `9`); local fixed build gave one bucket ending in `429`; public retest required after deployment |
| Reflected credentialed CORS | The backend now uses a configured UI allowlist without credentials, but Cloudflare's public wildcard CORS headers will remain by owner decision | Direct backend fix verified locally; live public responses still return wildcard CORS with credentials |
| Frontend lacked browser security headers | Added CSP, referrer policy, MIME sniffing protection, production HSTS, and removed the Next.js identifying header; development HTTP sources are scheme-based and iframe providers use one shared frontend allowlist | Development headers verified; production excludes `http:` and includes HSTS |
| Bearer tokens persisted in local storage | Authentication is session-storage only; old local-storage credentials are removed on application startup, login, and logout | Static scan shows no remaining token reads or writes outside the storage helper |
| Static JWT and seed-password fallbacks | JWT secret is required and at least 32 characters; seed passwords are required only when seed users must be created | Backend starts with the VM-provided secret; known fallback administrator login remains inactive |
| Health endpoint exposed environment and uptime | Public health response now contains only status, timestamp, and database state | Response verified locally |
| Known production dependency advisories | Sharp is overridden to `0.35.3`; Express's nested body-parser is overridden to `1.20.6` | Both production audits report zero vulnerabilities |
## Accepted Risk: Tracked VM Credentials
`backend/.env` remains tracked intentionally because this repository is used to
deliver credentials to the VM. This is an explicit operational decision by the
project owner.
Because this is intentional, operators should:
- Keep repository access restricted to trusted operators.
- Do not paste the file or Git history into public tickets, logs, or support
tools.
- Rotate credentials immediately if repository access expands or a clone is
lost.
- Use separate credentials for unrelated systems so repository exposure does
not create cross-system compromise.
## Accepted Risk: Cloudflare Wildcard CORS
Cloudflare currently adds `Access-Control-Allow-Origin: *` and
`Access-Control-Allow-Credentials: true` to frontend and API responses. These
headers will remain for the current internal-use deployment and its limited
set of public production presentations.
The backend allowlist remains in place for direct and non-Cloudflare traffic.
The present bearer token is stored in session storage and is not automatically
sent by a browser visiting another origin. The accepted exposure is therefore
mainly unauthenticated public API data, which other websites can call and read.
Revisit this decision before switching to cookie authentication, exposing
sensitive anonymous endpoints, or allowing broader external platform use.
## Choices Kept Deliberately Simple
- Authentication remains bearer-token based; migration to HttpOnly cookies and
CSRF tokens was not introduced. Session-only storage plus CSP provides a
proportional improvement without rewriting authentication.
- Rate limiting remains in memory because the deployment is a single backend
process. Redis is not required for the current topology.
- Swagger remains public, but it now receives Helmet security headers. It does
not expose credentials.
- The file download proxy remains public for presentation playback and offline
support. Storage keys continue to act as opaque resource identifiers;
presigned URL issuance is now presentation-scoped.
## Verification Summary
- Backend strict typecheck and lint: passed
- Frontend strict typecheck and lint: passed
- Backend tests: 82 unit, 14 database integration, and 3 HTTP E2E tests passed
- Frontend tests: 316 unit and 14 Playwright browser tests passed
- Frontend production build: passed
- Backend and frontend `npm audit --omit=dev`: zero vulnerabilities
- Public-role database hardening audit: passed
- VM Public-role audit: passed against the live database
- VM processes: online with zero unstable backend restarts
- VM disk: reduced from 94% to 72% by clearing the unused Yarn v6 cache
- Public TLS certificate covers `tbp.flatlogic.app` and is valid through
2026-09-11; TLS 1.1 is rejected and TLS 1.2 returns `200`
## Live Baseline Before Deployment
The public site was tested before committing or deploying these changes. It
still runs the previous build:
- frontend responses expose `X-Powered-By` and do not include the new CSP
- `/api/health` exposes `dev_stage` and process uptime
- a production runtime request without a project slug returns `200`
- anonymous presigning without presentation context returns `200`
- direct backend CORS reflects an attacker origin with credentials
- Cloudflare replaces the public CORS result with wildcard response headers
- changing only `X-Forwarded-For` produced separate login limiter buckets
These live results describe the old deployment. They do not invalidate the
local verification, but each fixed response must be retested after deployment.
## Deployment Check Still Required
The application-level forwarded-IP test passed, but the live old build still
allows the spoofing bypass. After deployment, repeat the two-request public
probe from `deployment-vm.md`: different supplied forwarding values must use
the same bucket (`9`, then `8`). The VM origin was not reachable directly on
port 80 during this assessment; retest that assumption after firewall or tunnel
changes.
## When to Retest
Repeat targeted security testing when authentication moves to cookies, the
backend scales to multiple processes, storage keys become predictable, public
upload endpoints are added, or the repository is shared outside the trusted VM
operations group.

View File

@ -1128,7 +1128,7 @@ Each entity follows a consistent structure:
```javascript ```javascript
const nextConfig = { const nextConfig = {
reactStrictMode: true, poweredByHeader: false,
typescript: { typescript: {
ignoreBuildErrors: false, // Enforce type checking ignoreBuildErrors: false, // Enforce type checking
@ -1140,6 +1140,10 @@ const nextConfig = {
devIndicators: false, // Avoid Pages Router dev static indicator HMR crash. devIndicators: false, // Avoid Pages Router dev static indicator HMR crash.
// All routes receive CSP, Referrer-Policy, and nosniff headers.
// Production responses also receive HSTS.
async headers() { /* securityHeaders */ },
images: { images: {
domains: ['cdn.platform.com', 's3.amazonaws.com'], domains: ['cdn.platform.com', 's3.amazonaws.com'],
}, },
@ -1149,6 +1153,13 @@ const nextConfig = {
}; };
``` ```
The CSP permits generic `http:` image, media, API, and WebSocket sources only
in development, so local ports do not need to be duplicated in the header
configuration. Production remains restricted to same-origin and HTTPS/WSS.
Trusted iframe providers are defined once in
`src/config/embedDomains.json`; both CSP `frame-src` generation and frontend
embed URL validation consume that list.
### Tailwind Config ### Tailwind Config
Theme customization in `css/_theme.css`: Theme customization in `css/_theme.css`:

View File

@ -126,7 +126,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
```typescript ```typescript
// Request interceptor - attach JWT token // Request interceptor - attach JWT token
axios.interceptors.request.use((config) => { axios.interceptors.request.use((config) => {
const token = sessionStorage.getItem('token') || localStorage.getItem('token'); const token = getStoredAuthToken();
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
@ -144,8 +144,7 @@ axios.interceptors.response.use(
// Handle 401 - clear tokens, redirect to login // Handle 401 - clear tokens, redirect to login
if (status === 401 && !isLoginRequest) { if (status === 401 && !isLoginRequest) {
sessionStorage.removeItem('token'); clearAuthSession();
localStorage.removeItem('token');
window.location.href = '/login'; window.location.href = '/login';
} }
return Promise.reject(error); return Promise.reject(error);

View File

@ -326,11 +326,10 @@ interface AuthState {
```typescript ```typescript
builder.addCase(loginUser.fulfilled, (state, action) => { builder.addCase(loginUser.fulfilled, (state, action) => {
const token = action.payload; const token = action.payload;
const user = jwt.decode(token); const user = decodeAuthToken(token);
state.token = token; state.token = token;
sessionStorage.setItem('token', token); storeAuthSession(token, user);
localStorage.setItem('token', token);
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
}); });
``` ```
@ -941,7 +940,7 @@ Redux is for client/app state. Use Redux slices for:
| State Type | Example | Why Redux | | State Type | Example | Why Redux |
|------------|---------|-----------| |------------|---------|-----------|
| **Authentication** | Current user, JWT token | App-wide, persisted to localStorage | | **Authentication** | Current user, JWT token | App-wide; credentials use sessionStorage |
| **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app | | **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
| **Layout/App UI** | Sidebar, theme, app preferences | Shared client state | | **Layout/App UI** | Sidebar, theme, app preferences | Shared client state |
| **Constructor UI State** | Selected elements, canvas state | Shared builder interactions | | **Constructor UI State** | Selected elements, canvas state | Shared builder interactions |

View File

@ -4,10 +4,56 @@
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import withSerwistInit from '@serwist/next'; import withSerwistInit from '@serwist/next';
import allowedEmbedDomains from './src/config/embedDomains.json' with { type: 'json' };
const output = process.env.NEXT_OUTPUT || undefined; const output = process.env.NEXT_OUTPUT || undefined;
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const isDevelopment = process.env.NODE_ENV === 'development'; const isDevelopment = process.env.NODE_ENV === 'development';
const developmentHttpSources = isDevelopment ? ' http:' : '';
const embedFrameSources = allowedEmbedDomains.flatMap((domain) => [
`https://${domain}`,
`https://*.${domain}`,
]);
const contentSecurityPolicy = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${isDevelopment ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: blob: https:${developmentHttpSources}`,
`media-src 'self' blob: https:${developmentHttpSources}`,
`connect-src 'self' https: wss:${isDevelopment ? ' http: ws:' : ''}`,
[
"frame-src 'self'",
...embedFrameSources,
].join(' '),
"font-src 'self' data:",
"worker-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ');
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: contentSecurityPolicy,
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
...(isDevelopment
? []
: [
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
]),
];
// Configure Serwist for production service worker generation. // Configure Serwist for production service worker generation.
const withSerwist = withSerwistInit({ const withSerwist = withSerwistInit({
@ -19,6 +65,7 @@ const withSerwist = withSerwistInit({
const nextConfig = { const nextConfig = {
trailingSlash: true, trailingSlash: true,
poweredByHeader: false,
distDir: isDevelopment ? '.next' : 'build', distDir: isDevelopment ? '.next' : 'build',
outputFileTracingRoot: __dirname, outputFileTracingRoot: __dirname,
output, output,
@ -30,6 +77,14 @@ const nextConfig = {
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: false,
}, },
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
images: { images: {
unoptimized: true, unoptimized: true,
remotePatterns: [ remotePatterns: [

View File

@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
@ -873,9 +874,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1002,9 +1003,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1058,9 +1059,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1070,19 +1071,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4" "@img/sharp-libvips-darwin-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1092,19 +1093,38 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4" "@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1118,9 +1138,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1134,9 +1154,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1150,9 +1170,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1166,9 +1186,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1182,9 +1202,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1198,9 +1218,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1214,9 +1234,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1230,9 +1250,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1246,9 +1266,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1262,9 +1282,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1274,19 +1294,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4" "@img/sharp-libvips-linux-arm": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1296,19 +1316,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4" "@img/sharp-libvips-linux-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1318,19 +1338,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4" "@img/sharp-libvips-linux-ppc64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1340,19 +1360,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4" "@img/sharp-libvips-linux-riscv64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1362,19 +1382,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4" "@img/sharp-libvips-linux-s390x": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1384,19 +1404,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4" "@img/sharp-libvips-linux-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1406,19 +1426,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4" "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1428,38 +1448,54 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4" "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.11.1"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1469,16 +1505,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1488,16 +1524,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1507,7 +1543,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@ -4871,9 +4907,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-import/node_modules/brace-expansion": { "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4965,9 +5001,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5042,9 +5078,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint-plugin-react/node_modules/brace-expansion": { "node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5150,9 +5186,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint/node_modules/brace-expansion": { "node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7987,9 +8023,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rimraf/node_modules/brace-expansion": { "node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.15", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -8196,48 +8232,66 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
"semver": "^7.7.3" "semver": "^7.8.5"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.34.5", "@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-linux-arm": "0.34.5", "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-x64": "0.34.5", "@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-wasm32": "0.34.5", "@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-win32-arm64": "0.34.5", "@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-x64": "0.34.5" "@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
} }
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {

View File

@ -15,7 +15,8 @@
"format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write" "format": "prettier '{components,pages,src,interfaces,hooks}/**/*.{tsx,ts,js}' --write"
}, },
"overrides": { "overrides": {
"postcss": "^8.5.16" "postcss": "^8.5.16",
"sharp": "0.35.3"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.11.3",

View File

@ -1,5 +1,6 @@
import type { NextRouter } from 'next/router'; import type { NextRouter } from 'next/router';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { getStoredAuthToken } from '../../lib/authStorage';
export function useConstructorAuthRedirect({ export function useConstructorAuthRedirect({
router, router,
@ -17,8 +18,7 @@ export function useConstructorAuthRedirect({
useEffect(() => { useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return; if (!router.isReady || typeof window === 'undefined') return;
const token = const token = getStoredAuthToken();
sessionStorage.getItem('token') || localStorage.getItem('token');
if (!token) { if (!token) {
setIsAuthReady(false); setIsAuthReady(false);
setErrorMessage('Please sign in to continue.'); setErrorMessage('Please sign in to continue.');

View File

@ -25,7 +25,10 @@ import { usePageNavigation } from '../hooks/usePageNavigation';
import { useProjectAssets } from '../hooks/useProjectAssets'; import { useProjectAssets } from '../hooks/useProjectAssets';
import { useVideoSoundControl } from '../hooks/useVideoSoundControl'; import { useVideoSoundControl } from '../hooks/useVideoSoundControl';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; import {
resolveAssetPlaybackUrl,
setPresignRuntimeContext,
} from '../lib/assetUrl';
import { backgroundAudioController } from '../lib/backgroundAudioController'; import { backgroundAudioController } from '../lib/backgroundAudioController';
import { isElementFlagEnabled } from '../lib/elementFlags'; import { isElementFlagEnabled } from '../lib/elementFlags';
import { isInfoPanelElementType } from '../lib/elementTypeGuards'; import { isInfoPanelElementType } from '../lib/elementTypeGuards';
@ -97,6 +100,11 @@ export default function RuntimePresentation({
[environment, projectSlug], [environment, projectSlug],
); );
useEffect(() => {
setPresignRuntimeContext({ projectSlug, environment });
return () => setPresignRuntimeContext(null);
}, [environment, projectSlug]);
const { project, pages, isLoading, error, errorStatus, initialPageId } = const { project, pages, isLoading, error, errorStatus, initialPageId } =
usePageDataLoader({ usePageDataLoader({
projectSlug, projectSlug,

View File

@ -0,0 +1,10 @@
[
"matterport.com",
"kuula.co",
"roundme.com",
"sketchfab.com",
"youtube.com",
"vimeo.com",
"google.com",
"360stories.com"
]

View File

@ -20,13 +20,12 @@ import { logger } from '../lib/logger';
import { useGlobalAudioMute } from './useGlobalAudioMute'; import { useGlobalAudioMute } from './useGlobalAudioMute';
/** /**
* Fetch audio file with credentials and return a blob URL. * Fetch an audio file and return a blob URL.
* This handles authenticated URLs that require cookies/headers.
*/ */
async function fetchAudioAsBlobUrl(url: string): Promise<string | null> { async function fetchAudioAsBlobUrl(url: string): Promise<string | null> {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include', // Include cookies for auth credentials: 'omit',
}); });
if (!response.ok) { if (!response.ok) {
logger.warn('[AudioEffects] Failed to fetch audio:', { logger.warn('[AudioEffects] Failed to fetch audio:', {

View File

@ -10,6 +10,7 @@ import NavBar from '../components/NavBar';
import NavBarItemPlain from '../components/NavBarItemPlain'; import NavBarItemPlain from '../components/NavBarItemPlain';
import Search from '../components/Search'; import Search from '../components/Search';
import { isAuthTokenValid } from '../lib/authToken'; import { isAuthTokenValid } from '../lib/authToken';
import { getStoredAuthToken } from '../lib/authStorage';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import menuAside from '../menuAside'; import menuAside from '../menuAside';
import menuNavBar from '../menuNavBar'; import menuNavBar from '../menuNavBar';
@ -55,15 +56,10 @@ export default function LayoutAuthenticated({
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false); const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
const [isAsideLgActive, setIsAsideLgActive] = useState(false); const [isAsideLgActive, setIsAsideLgActive] = useState(false);
const getStoredToken = () => {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem('token') || localStorage.getItem('token');
};
useEffect(() => { useEffect(() => {
if (!router.isReady) return; if (!router.isReady) return;
const storedToken = getStoredToken(); const storedToken = getStoredAuthToken();
const authToken = token || storedToken; const authToken = token || storedToken;
if (!authToken || !isAuthTokenValid(authToken)) { if (!authToken || !isAuthTokenValid(authToken)) {

View File

@ -9,6 +9,19 @@ import axios, { AxiosError } from 'axios';
import { baseURLApi } from '../config'; import { baseURLApi } from '../config';
import { logger } from './logger'; import { logger } from './logger';
interface PresignRuntimeContext {
projectSlug: string;
environment: 'stage' | 'production';
}
let presignRuntimeContext: PresignRuntimeContext | null = null;
export const setPresignRuntimeContext = (
context: PresignRuntimeContext | null,
): void => {
presignRuntimeContext = context;
};
/** /**
* Check if a URL is a presigned S3 URL * Check if a URL is a presigned S3 URL
*/ */
@ -97,11 +110,21 @@ const BATCH_DELAY_MS = 10; // Small delay to batch concurrent requests
const fetchPresignedUrlsBatch = async ( const fetchPresignedUrlsBatch = async (
urls: string[], urls: string[],
): Promise<Record<string, string>> => { ): Promise<Record<string, string>> => {
const runtimeHeaders = presignRuntimeContext
? {
'X-Runtime-Project-Slug': presignRuntimeContext.projectSlug,
'X-Runtime-Environment': presignRuntimeContext.environment,
}
: undefined;
// Use explicit baseURLApi to avoid double /api/ prefix issues // Use explicit baseURLApi to avoid double /api/ prefix issues
const response = await axios.post<{ presignedUrls: Record<string, string> }>( const response = await axios.post<{ presignedUrls: Record<string, string> }>(
`${baseURLApi}/file/presign`, `${baseURLApi}/file/presign`,
{ urls }, { urls },
{ baseURL: '' }, // Override baseURL to prevent duplication {
baseURL: '', // Override baseURL to prevent duplication
headers: runtimeHeaders,
},
); );
// Cache the results // Cache the results

View File

@ -0,0 +1,38 @@
const TOKEN_KEY = 'token';
const USER_KEY = 'user';
const canUseBrowserStorage = (): boolean => typeof window !== 'undefined';
export const getStoredAuthToken = (): string | null => {
if (!canUseBrowserStorage()) return null;
// Clear credentials left by older builds as soon as the application loads.
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
return sessionStorage.getItem(TOKEN_KEY);
};
export const storeAuthSession = (token: string, user?: unknown): void => {
if (!canUseBrowserStorage()) return;
sessionStorage.setItem(TOKEN_KEY, token);
if (user) {
sessionStorage.setItem(USER_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(USER_KEY);
}
// Remove tokens written by older builds. Authentication is intentionally
// session-scoped so closing the browser clears bearer credentials.
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
};
export const clearAuthSession = (): void => {
if (!canUseBrowserStorage()) return;
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(USER_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
};

View File

@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isValidEmbedUrl } from './embedUrl';
test('isValidEmbedUrl accepts HTTPS provider roots and subdomains', () => {
assert.equal(isValidEmbedUrl('https://matterport.com/show/123'), true);
assert.equal(isValidEmbedUrl('https://my.matterport.com/show/123'), true);
});
test('isValidEmbedUrl rejects untrusted domains and non-HTTPS URLs', () => {
assert.equal(isValidEmbedUrl('https://attacker.example/embed'), false);
assert.equal(isValidEmbedUrl('http://kuula.co/share/123'), false);
});

View File

@ -2,28 +2,14 @@
* Helpers for trusted third-party embed URLs. * Helpers for trusted third-party embed URLs.
*/ */
const ALLOWED_EMBED_DOMAINS = [ import allowedEmbedDomains from '../config/embedDomains.json';
'matterport.com',
'my.matterport.com',
'kuula.co',
'roundme.com',
'sketchfab.com',
'youtube.com',
'www.youtube.com',
'vimeo.com',
'player.vimeo.com',
'google.com',
'maps.google.com',
'www.google.com',
'docs.google.com',
'drive.google.com',
'360stories.com',
];
export const isValidEmbedUrl = (url: string): boolean => { export const isValidEmbedUrl = (url: string): boolean => {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return ALLOWED_EMBED_DOMAINS.some( if (parsed.protocol !== 'https:') return false;
return allowedEmbedDomains.some(
(domain) => (domain) =>
parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`), parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
); );

View File

@ -7,6 +7,7 @@
import axios from 'axios'; import axios from 'axios';
import { resolveAssetPlaybackUrl } from './assetUrl'; import { resolveAssetPlaybackUrl } from './assetUrl';
import { getStoredAuthToken } from './authStorage';
import { logger } from './logger'; import { logger } from './logger';
/** /**
@ -131,8 +132,7 @@ export const resolveDurationWithFallback = async (
? playbackUrl ? playbackUrl
: playbackUrl.replace(/^\/api(?=\/)/, ''); : playbackUrl.replace(/^\/api(?=\/)/, '');
const token = const token = getStoredAuthToken() || '';
typeof window !== 'undefined' ? localStorage.getItem('token') || '' : '';
const response = await axios.get(requestUrl, { const response = await axios.get(requestUrl, {
responseType: 'blob', responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined, headers: token ? { Authorization: `Bearer ${token}` } : undefined,

View File

@ -23,6 +23,7 @@ import '../css/main.css';
import '../i18n'; import '../i18n';
import { disablePresignedUrls } from '../lib/assetUrl'; import { disablePresignedUrls } from '../lib/assetUrl';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { clearAuthSession, getStoredAuthToken } from '../lib/authStorage';
import { import {
appSteps, appSteps,
loginSteps, loginSteps,
@ -47,8 +48,7 @@ axios.defaults.headers.common['Content-Type'] = 'application/json';
axios.interceptors.request.use( axios.interceptors.request.use(
(config) => { (config) => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const token = const token = getStoredAuthToken();
sessionStorage.getItem('token') || localStorage.getItem('token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} else { } else {
@ -85,10 +85,7 @@ axios.interceptors.response.use(
if (status === 401 && !isLoginRequest) { if (status === 401 && !isLoginRequest) {
// Clear stored tokens // Clear stored tokens
sessionStorage.removeItem('token'); clearAuthSession();
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
delete axios.defaults.headers.common['Authorization']; delete axios.defaults.headers.common['Authorization'];
// Redirect to login if not already there // Redirect to login if not already there

View File

@ -4,13 +4,13 @@ import type { ReactElement } from 'react';
import React from 'react'; import React from 'react';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getStoredAuthToken } from '../lib/authStorage';
export default function HomeRedirect() { export default function HomeRedirect() {
const router = useRouter(); const router = useRouter();
React.useEffect(() => { React.useEffect(() => {
const token = const token = getStoredAuthToken();
sessionStorage.getItem('token') || localStorage.getItem('token');
router.replace(token ? '/projects/projects-list' : '/login'); router.replace(token ? '/projects/projects-list' : '/login');
}, [router]); }, [router]);

View File

@ -1,6 +1,7 @@
import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { createAction, createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import axios from 'axios'; import axios from 'axios';
import { decodeAuthToken } from '../lib/authToken'; import { decodeAuthToken } from '../lib/authToken';
import { clearAuthSession, storeAuthSession } from '../lib/authStorage';
import type { AuthState } from '../types/redux'; import type { AuthState } from '../types/redux';
const initialState: AuthState = { const initialState: AuthState = {
@ -63,10 +64,7 @@ export const authSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
logoutUser: (state) => { logoutUser: (state) => {
sessionStorage.removeItem('token'); clearAuthSession();
sessionStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('user');
axios.defaults.headers.common['Authorization'] = ''; axios.defaults.headers.common['Authorization'] = '';
state.currentUser = null; state.currentUser = null;
state.token = ''; state.token = '';
@ -83,15 +81,7 @@ export const authSlice = createSlice({
state.errorMessage = ''; state.errorMessage = '';
state.isFetching = false; state.isFetching = false;
state.token = token; state.token = token;
sessionStorage.setItem('token', token); storeAuthSession(token, user);
localStorage.setItem('token', token);
if (user) {
sessionStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('user', JSON.stringify(user));
} else {
sessionStorage.removeItem('user');
localStorage.removeItem('user');
}
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
}); });

View File

@ -437,7 +437,7 @@ export async function authenticate(page: Page, user = testUser) {
window.localStorage.setItem('user', JSON.stringify(user)); window.localStorage.setItem('user', JSON.stringify(user));
window.sessionStorage.setItem('user', JSON.stringify(user)); window.sessionStorage.setItem('user', JSON.stringify(user));
}, },
{ token: testToken, user: testUser }, { token: testToken, user },
); );
} }

View File

@ -1,3 +1,5 @@
# Docker development entrypoint only. The standard VM uses Apache.
# Nginx receives client traffic directly, so it replaces supplied X-Forwarded-For.
worker_processes 1; worker_processes 1;
events { events {
@ -23,16 +25,15 @@ http {
location /api/logError { location /api/logError {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:3001/api/logError; proxy_pass http://127.0.0.1:3001/api/logError;
} }
location /api-docs/ { location /api-docs/ {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:3000/api-docs/; proxy_pass http://127.0.0.1:3000/api-docs/;
} }
@ -40,19 +41,15 @@ http {
location /api/ { location /api/ {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range';
proxy_pass http://127.0.0.1:3000/api/; proxy_pass http://127.0.0.1:3000/api/;
} }
location / { location / {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:3001/; proxy_pass http://127.0.0.1:3001/;
} }
@ -60,7 +57,7 @@ http {
location /app-shell/ { location /app-shell/ {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:4000/; proxy_pass http://127.0.0.1:4000/;
} }
@ -80,7 +77,7 @@ http {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
@ -89,8 +86,8 @@ http {
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
} }