diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..997b0b5 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,29 @@ +name: Security Audit + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +jobs: + dependency-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Fail on critical runtime vulnerabilities + run: npm audit --omit=dev --audit-level=critical diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ed940..7c5f219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to ProcessAce will be documented in this file. +## [1.3.2] - 2026-03-23 + +### Added + +- CSRF protection middleware for state-changing API requests, including cookie-backed token issuance and client-side automatic header propagation. +- Centralized storage-path configuration for evidence uploads to reduce path drift across API handlers. +- CI dependency-audit workflow gate to fail builds on known vulnerable packages. +- Security regression tests for CSRF coverage, invitation hardening, evidence upload constraints, and admin users pagination validation. + +### Changed + +- Invitation acceptance now binds tokens to the intended recipient account and enforces ownership checks during acceptance. +- Markdown rendering in dashboard/admin artifact views now routes through shared HTML sanitization before insertion in the DOM. +- Admin users pagination now validates and clamps page and limit parameters before querying. + +### Fixed + +- Removed a personal workspace ownership-transfer bypass edge case by hardening workspace ownership checks. +- Corrected dashboard CSP nonce test expectations to match current script injection behavior in the secured UI shell. + ## [1.3.1] - 2026-03-19 ### Changed diff --git a/package-lock.json b/package-lock.json index 1494c14..f5a9e25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "processace", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "processace", - "version": "1.3.1", + "version": "1.3.2", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@anthropic-ai/sdk": "^0.78.0", diff --git a/package.json b/package.json index 40ec9a8..f94806b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "processace", - "version": "1.3.1", + "version": "1.3.2", "description": "AI-powered process discovery and documentation engine – from recordings and docs to BPMN 2.0, SIPOC and RACI. Self-hosted with bring-your-own LLM.", "author": "jgleiser", "license": "SEE LICENSE IN LICENSE.md", diff --git a/src/api/admin.js b/src/api/admin.js index ee25221..753174c 100644 --- a/src/api/admin.js +++ b/src/api/admin.js @@ -13,6 +13,18 @@ const router = express.Router(); router.use(authenticateToken); router.use(requireAdmin); +const parsePositiveInteger = (rawValue, fallback) => { + if (typeof rawValue === 'undefined') { + return fallback; + } + + if (typeof rawValue !== 'string' || !/^\d+$/.test(rawValue)) { + return NaN; + } + + return Number(rawValue); +}; + /** * GET /api/admin/users * Get all users (admin only) @@ -24,8 +36,16 @@ router.get('/users', (req, res) => { res.set('Expires', '0'); res.set('Pragma', 'no-cache'); - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 10; + const page = parsePositiveInteger(req.query.page, 1); + const limit = parsePositiveInteger(req.query.limit, 10); + + if (!Number.isInteger(page) || page < 1) { + return res.status(400).json({ error: 'Invalid pagination parameters: page must be a positive integer.' }); + } + + if (!Number.isInteger(limit) || limit < 1 || limit > authService.MAX_USERS_PAGE_LIMIT) { + return res.status(400).json({ error: `Invalid pagination parameters: limit must be between 1 and ${authService.MAX_USERS_PAGE_LIMIT}.` }); + } // Extract filters const filters = { @@ -59,6 +79,10 @@ router.get('/users', (req, res) => { }, }); } catch (error) { + if (error.message.startsWith('Invalid pagination parameters:')) { + return res.status(400).json({ error: error.message }); + } + logger.error({ err: error }, 'Error fetching users'); res.status(500).json({ error: 'Failed to fetch users' }); } diff --git a/src/api/evidence.js b/src/api/evidence.js index e7eb4ec..ee412fd 100644 --- a/src/api/evidence.js +++ b/src/api/evidence.js @@ -11,12 +11,12 @@ const { auditMiddleware } = require('../middleware/auditMiddleware'); const { AppError, sendErrorResponse } = require('../utils/errorResponse'); const { sanitizeFilename } = require('../utils/sanitizeFilename'); const { isAdminRole } = require('../utils/roles'); +const { UPLOADS_DIR } = require('../config/storagePaths'); const router = express.Router(); const parsedMaxUploadSizeMb = Number.parseInt(process.env.MAX_UPLOAD_SIZE_MB || '100', 10); const MAX_UPLOAD_SIZE_MB = Number.isFinite(parsedMaxUploadSizeMb) && parsedMaxUploadSizeMb > 0 ? parsedMaxUploadSizeMb : 100; const MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024; -const UPLOADS_DIR = path.resolve(process.cwd(), 'uploads'); const ALLOWED_UPLOAD_EXTENSIONS = new Set([ '.mp3', '.m4a', @@ -37,6 +37,8 @@ const ALLOWED_UPLOAD_EXTENSIONS = new Set([ const isWithinUploadsDir = (absolutePath) => absolutePath === UPLOADS_DIR || absolutePath.startsWith(`${UPLOADS_DIR}${path.sep}`); +fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + const resolveTranscriptAudioVariant = async (evidence, logger) => { const metadata = evidence.metadata && typeof evidence.metadata === 'object' ? evidence.metadata : {}; const transcriptionMetadata = metadata.transcription && typeof metadata.transcription === 'object' ? metadata.transcription : {}; @@ -86,8 +88,8 @@ const resolveTranscriptAudioVariant = async (evidence, logger) => { // Configure storage const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, 'uploads/'); + destination: function (_req, _file, cb) { + cb(null, UPLOADS_DIR); }, filename: function (req, file, cb) { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); diff --git a/src/api/invitations.js b/src/api/invitations.js index 5f57b43..81e849b 100644 --- a/src/api/invitations.js +++ b/src/api/invitations.js @@ -70,6 +70,9 @@ router.post('/:token/accept', authenticateToken, async (req, res) => { res.json(result); } catch (error) { logger.error({ err: error }, 'Error accepting invitation'); + if (error.message === 'Invitation does not belong to authenticated user') { + return res.status(403).json({ error: error.message }); + } if (error.message === 'Invalid invitation' || error.message === 'Invitation expired') { return res.status(400).json({ error: error.message }); } @@ -85,14 +88,17 @@ router.post('/:token/decline', authenticateToken, async (req, res) => { try { const { token } = req.params; - if (!req.user) { + if (!req.user || !req.user.id) { return res.status(401).json({ error: 'Authentication required' }); } - const result = workspaceService.declineInvitation(token); + const result = workspaceService.declineInvitation(token, req.user.id); res.json(result); } catch (error) { logger.error({ err: error }, 'Error declining invitation'); + if (error.message === 'Invitation does not belong to authenticated user') { + return res.status(403).json({ error: error.message }); + } if (error.message === 'Invalid invitation') { return res.status(400).json({ error: error.message }); } diff --git a/src/app.js b/src/app.js index 7418765..b258aa3 100644 --- a/src/app.js +++ b/src/app.js @@ -8,6 +8,8 @@ const path = require('path'); const crypto = require('crypto'); const cookieParser = require('cookie-parser'); const { generateCorrelationId, sendErrorResponse } = require('./utils/errorResponse'); +const { parseCorsOrigins } = require('./utils/corsOrigins'); +const { ensureCsrfTokenCookie, enforceCsrfProtection } = require('./middleware/csrf'); const app = express(); const publicDir = path.join(__dirname, 'public'); @@ -51,28 +53,6 @@ const serveHtmlWithNonce = async (req, res, next) => { } }; -// CORS Configuration -const parseCorsOrigins = () => { - const envOrigins = process.env.CORS_ALLOWED_ORIGINS; - - if (envOrigins) { - const parsedOrigins = envOrigins - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); - - if (parsedOrigins.length > 0) { - return parsedOrigins; - } - } - - if (process.env.NODE_ENV === 'production') { - throw new Error('CORS_ALLOWED_ORIGINS must be set in production and contain at least one allowed origin.'); - } - - return ['http://localhost:3000', 'http://processace.local:3000']; -}; - app.use(attachRequestContext); app.use( @@ -100,6 +80,8 @@ app.use( app.use(express.json()); app.use(cookieParser()); +app.use(ensureCsrfTokenCookie); +app.use(enforceCsrfProtection); // Request logging middleware app.use((req, res, next) => { diff --git a/src/config/storagePaths.js b/src/config/storagePaths.js new file mode 100644 index 0000000..5aefbbf --- /dev/null +++ b/src/config/storagePaths.js @@ -0,0 +1,10 @@ +const path = require('path'); + +const resolveUploadsDirectory = (env = process.env, cwd = process.cwd()) => path.resolve(cwd, env.UPLOADS_DIR || 'uploads'); + +const UPLOADS_DIR = resolveUploadsDirectory(); + +module.exports = { + resolveUploadsDirectory, + UPLOADS_DIR, +}; diff --git a/src/middleware/csrf.js b/src/middleware/csrf.js new file mode 100644 index 0000000..da6f7c9 --- /dev/null +++ b/src/middleware/csrf.js @@ -0,0 +1,94 @@ +const crypto = require('crypto'); +const { parseCorsOrigins } = require('../utils/corsOrigins'); + +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_HEADER_NAME = 'x-csrf-token'; +const CSRF_TOKEN_BYTES = 32; +const CSRF_SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +const CSRF_EXEMPT_PATHS = new Set(['/api/auth/login', '/api/auth/register']); + +const shouldBypassCsrfForTesting = () => process.env.NODE_ENV === 'test' && process.env.ENFORCE_TEST_CSRF !== 'true'; + +const getCookieOptions = () => ({ + httpOnly: false, + maxAge: 24 * 60 * 60 * 1000, + path: '/', + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', +}); + +const normalizeOrigin = (value) => { + if (!value || typeof value !== 'string') { + return null; + } + + try { + return new URL(value).origin; + } catch { + return null; + } +}; + +const getAllowedOrigins = (req) => { + const configuredOrigins = parseCorsOrigins() + .map((origin) => normalizeOrigin(origin)) + .filter(Boolean); + const requestHostOrigin = normalizeOrigin(`${req.protocol}://${req.get('host')}`); + + return new Set(requestHostOrigin ? [...configuredOrigins, requestHostOrigin] : configuredOrigins); +}; + +const resolveRequestOrigin = (req) => normalizeOrigin(req.get('origin')) || normalizeOrigin(req.get('referer')); + +const ensureCsrfTokenCookie = (req, res, next) => { + if (shouldBypassCsrfForTesting()) { + return next(); + } + + const existingToken = typeof req.cookies?.[CSRF_COOKIE_NAME] === 'string' ? req.cookies[CSRF_COOKIE_NAME] : ''; + const csrfToken = existingToken || crypto.randomBytes(CSRF_TOKEN_BYTES).toString('hex'); + + if (!existingToken) { + res.cookie(CSRF_COOKIE_NAME, csrfToken, getCookieOptions()); + } + + req.csrfToken = csrfToken; + return next(); +}; + +const enforceCsrfProtection = (req, res, next) => { + if (shouldBypassCsrfForTesting()) { + return next(); + } + + if (CSRF_SAFE_METHODS.has(req.method)) { + return next(); + } + + if (!req.path.startsWith('/api') || CSRF_EXEMPT_PATHS.has(req.path)) { + return next(); + } + + const requestOrigin = resolveRequestOrigin(req); + const allowedOrigins = getAllowedOrigins(req); + + if (!requestOrigin || !allowedOrigins.has(requestOrigin)) { + return res.status(403).json({ error: 'Invalid request origin' }); + } + + const cookieToken = req.cookies?.[CSRF_COOKIE_NAME]; + const headerToken = req.get(CSRF_HEADER_NAME); + + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return res.status(403).json({ error: 'Invalid CSRF token' }); + } + + return next(); +}; + +module.exports = { + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + enforceCsrfProtection, + ensureCsrfTokenCookie, +}; diff --git a/src/public/admin-jobs.html b/src/public/admin-jobs.html index f7e2ada..38ed032 100644 --- a/src/public/admin-jobs.html +++ b/src/public/admin-jobs.html @@ -164,8 +164,10 @@

Artifact View

+ + diff --git a/src/public/index.html b/src/public/index.html index 6ddc01e..b68655a 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -155,8 +155,10 @@

Create Workspace

+ + diff --git a/src/public/js/admin-jobs.js b/src/public/js/admin-jobs.js index fa4b715..e220590 100644 --- a/src/public/js/admin-jobs.js +++ b/src/public/js/admin-jobs.js @@ -9,7 +9,7 @@ let totalPages = 1; let jobsData = []; // Store jobs for modal access const t = window.i18n ? window.i18n.t.bind(window.i18n) : (k) => k; -/* global marked, BpmnJS */ +/* global BpmnJS */ // DOM Elements const loadingState = document.getElementById('loadingState'); @@ -605,12 +605,12 @@ function renderArtifactContent(type, content, _artifactId) { artifactModalBody.innerHTML = html; } else if (type === 'doc') { setModalClasses(false); - if (typeof marked === 'undefined') { + if (typeof window.renderSafeMarkdown !== 'function') { artifactModalBody.innerHTML = '

Error: Marked library not loaded.

'; return; } artifactModalBody.innerHTML = ` -
${marked.parse(content)}
+
${window.renderSafeMarkdown(content)}
`; } else { setModalClasses(false); diff --git a/src/public/js/api-client.js b/src/public/js/api-client.js index 27b57cf..d9676d6 100644 --- a/src/public/js/api-client.js +++ b/src/public/js/api-client.js @@ -2,6 +2,85 @@ * API Client for centralized fetch operations * Handles common headers, error parsing, and auth redirection */ +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_HEADER_NAME = 'x-csrf-token'; +const CSRF_UNSAFE_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']); + +const getCookieValue = (name) => { + const cookiePrefix = `${name}=`; + const cookies = document.cookie.split(';'); + + for (const cookie of cookies) { + const trimmedCookie = cookie.trim(); + if (trimmedCookie.startsWith(cookiePrefix)) { + return decodeURIComponent(trimmedCookie.slice(cookiePrefix.length)); + } + } + + return null; +}; + +const resolveRequestMethod = (input, options = {}) => { + if (typeof options.method === 'string' && options.method.trim().length > 0) { + return options.method.toUpperCase(); + } + + if (input && typeof input === 'object' && typeof input.method === 'string' && input.method.trim().length > 0) { + return input.method.toUpperCase(); + } + + return 'GET'; +}; + +const resolveRequestUrl = (input) => { + if (typeof input === 'string' || input instanceof URL) { + return new URL(input, window.location.origin); + } + + if (input && typeof input === 'object' && typeof input.url === 'string') { + return new URL(input.url, window.location.origin); + } + + return null; +}; + +const isSameOriginRequest = (input) => { + const requestUrl = resolveRequestUrl(input); + return requestUrl ? requestUrl.origin === window.location.origin : false; +}; + +const originalFetch = typeof window.fetch === 'function' ? window.fetch.bind(window) : null; + +if (originalFetch) { + window.fetch = (input, options = {}) => { + const method = resolveRequestMethod(input, options); + + if (!CSRF_UNSAFE_METHODS.has(method) || !isSameOriginRequest(input)) { + return originalFetch(input, options); + } + + const csrfToken = getCookieValue(CSRF_COOKIE_NAME); + if (!csrfToken) { + return originalFetch(input, options); + } + + const mergedHeaders = new Headers(input instanceof Request ? input.headers : undefined); + if (options.headers) { + const incomingHeaders = new Headers(options.headers); + incomingHeaders.forEach((value, key) => mergedHeaders.set(key, value)); + } + mergedHeaders.set(CSRF_HEADER_NAME, csrfToken); + + const nextOptions = { + ...options, + credentials: options.credentials || 'same-origin', + headers: mergedHeaders, + }; + + return originalFetch(input, nextOptions); + }; +} + const apiClient = { /** * Generic fetch wrapper diff --git a/src/public/js/components/artifact-viewer.js b/src/public/js/components/artifact-viewer.js index 59c0b60..214d7b4 100644 --- a/src/public/js/components/artifact-viewer.js +++ b/src/public/js/components/artifact-viewer.js @@ -2,7 +2,7 @@ * Artifact Viewer * Handles viewing and editing artifacts (BPMN, SIPOC, RACI, Narrative) inside a modal. */ -/* global marked, BpmnJS, EasyMDE */ +/* global BpmnJS, EasyMDE */ globalThis.ArtifactViewer = (function () { const t = () => (globalThis.i18n ? globalThis.i18n.t : (k) => k); @@ -249,7 +249,7 @@ globalThis.ArtifactViewer = (function () { const cancelBtn = document.getElementById('btn-cancel-table'); if (cancelBtn) cancelBtn.addEventListener('click', cancelTableEdit); } else if (type === 'doc') { - if (typeof marked === 'undefined') { + if (typeof globalThis.renderSafeMarkdown !== 'function') { modalBody.innerHTML = `

${t()('artifacts.markedNotLoaded')}

`; return; } @@ -265,7 +265,7 @@ globalThis.ArtifactViewer = (function () { -
${marked.parse(content)}
+
${globalThis.renderSafeMarkdown(content)}
`; @@ -617,7 +617,7 @@ globalThis.ArtifactViewer = (function () { function cancelDocEdit() { destroyDocEditor(); const viewDiv = document.getElementById('markdown-content'); - viewDiv.innerHTML = marked.parse(currentArtifactContent); + viewDiv.innerHTML = globalThis.renderSafeMarkdown(currentArtifactContent); viewDiv.style.display = 'block'; document.getElementById('editDoc').style.display = 'inline-block'; diff --git a/src/public/js/utils/markdown-sanitizer.js b/src/public/js/utils/markdown-sanitizer.js new file mode 100644 index 0000000..319e51a --- /dev/null +++ b/src/public/js/utils/markdown-sanitizer.js @@ -0,0 +1,47 @@ +/* global DOMPurify, marked */ +(function attachSafeMarkdownRenderer(globalScope) { + const escapeHtml = (value) => + String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); + + const createMarkedRenderer = () => { + if (typeof marked === 'undefined' || typeof marked.Renderer !== 'function') { + return null; + } + + const renderer = new marked.Renderer(); + // Rendering raw HTML from markdown is blocked to reduce scriptable surface area before sanitization. + renderer.html = () => ''; + return renderer; + }; + + const markedRenderer = createMarkedRenderer(); + + const parseMarkdown = (markdownText) => { + if (typeof marked === 'undefined' || typeof marked.parse !== 'function') { + return escapeHtml(markdownText); + } + + return marked.parse(markdownText, { + breaks: false, + gfm: true, + renderer: markedRenderer || undefined, + }); + }; + + globalScope.renderSafeMarkdown = (markdownText) => { + const normalizedMarkdown = typeof markdownText === 'string' ? markdownText : String(markdownText || ''); + const rawHtml = parseMarkdown(normalizedMarkdown); + + if (typeof DOMPurify === 'undefined' || typeof DOMPurify.sanitize !== 'function') { + return escapeHtml(normalizedMarkdown); + } + + return DOMPurify.sanitize(rawHtml, { + FORBID_ATTR: ['style'], + FORBID_TAGS: ['embed', 'form', 'iframe', 'object', 'script', 'style'], + USE_PROFILES: { + html: true, + }, + }); + }; +})(globalThis); diff --git a/src/services/authService.js b/src/services/authService.js index f43c663..431d2b3 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -8,6 +8,7 @@ const logger = require('../logging/logger'); const tokenBlocklist = require('./tokenBlocklist'); const { USER_ROLES, isAdminRole, isSuperAdminRole } = require('../utils/roles'); const { DEFAULT_PERSONAL_WORKSPACE_NAME, WORKSPACE_KINDS } = require('../utils/workspaces'); +const { UPLOADS_DIR } = require('../config/storagePaths'); const SALT_ROUNDS = 10; const JWT_EXPIRES_IN = '24h'; @@ -32,8 +33,7 @@ const APPROVABLE_STATUSES = new Set(['pending', 'rejected']); const LOCKOUT_THRESHOLD = 5; const LOCKOUT_DURATIONS_MINUTES = [15, 30, 60]; const CONSENT_TYPES = ['terms_of_service', 'data_processing']; -const UPLOADS_DIR = path.resolve(process.cwd(), process.env.UPLOADS_DIR || 'uploads'); - +const MAX_USERS_PAGE_LIMIT = 100; const resolveJwtSecret = () => { if (process.env.JWT_SECRET) { return process.env.JWT_SECRET; @@ -372,6 +372,14 @@ class AuthService { } getUsersPaginated(page = 1, limit = 10, filters = {}) { + if (!Number.isInteger(page) || page < 1) { + throw new Error('Invalid pagination parameters: page must be a positive integer.'); + } + + if (!Number.isInteger(limit) || limit < 1 || limit > MAX_USERS_PAGE_LIMIT) { + throw new Error(`Invalid pagination parameters: limit must be between 1 and ${MAX_USERS_PAGE_LIMIT}.`); + } + const offset = (page - 1) * limit; let whereClauses = []; @@ -807,3 +815,4 @@ module.exports.RESET_CONFIRMATION_ERROR = RESET_CONFIRMATION_ERROR; module.exports.SUPERADMIN_ACCOUNT_MANAGEMENT_ERROR = SUPERADMIN_ACCOUNT_MANAGEMENT_ERROR; module.exports.SUPERADMIN_ROLE_REQUIRED_ERROR = SUPERADMIN_ROLE_REQUIRED_ERROR; module.exports.USER_ROLES = USER_ROLES; +module.exports.MAX_USERS_PAGE_LIMIT = MAX_USERS_PAGE_LIMIT; diff --git a/src/services/workspaceService.js b/src/services/workspaceService.js index c9263ad..f802543 100644 --- a/src/services/workspaceService.js +++ b/src/services/workspaceService.js @@ -14,6 +14,8 @@ const { isTransferredPersonalWorkspaceName, } = require('../utils/workspaces'); +const normalizeEmail = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : ''); + class WorkspaceService { repairLegacyPersonalWorkspace(workspace) { if (!workspace) { @@ -378,11 +380,16 @@ class WorkspaceService { const token = uuidv4(); const now = new Date(); const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days + const normalizedRecipientEmail = normalizeEmail(email); + + if (!normalizedRecipientEmail) { + throw new Error('Recipient email is required'); + } // Fixed syntax error try { // 1. Check if user exists (ENFORCED) - const recipientUser = db.prepare('SELECT id, email, name FROM users WHERE email = ?').get(email); + const recipientUser = db.prepare('SELECT id, email, name FROM users WHERE lower(email) = ?').get(normalizedRecipientEmail); if (!recipientUser) { throw new Error('User must be registered to be invited'); } @@ -396,8 +403,8 @@ class WorkspaceService { // Check if invite already exists, update it if so const existingInvite = db - .prepare('SELECT id FROM workspace_invitations WHERE workspace_id = ? AND recipient_email = ?') - .get(workspaceId, email); + .prepare('SELECT id FROM workspace_invitations WHERE workspace_id = ? AND lower(recipient_email) = ?') + .get(workspaceId, normalizedRecipientEmail); if (existingInvite) { db.prepare( @@ -414,7 +421,7 @@ class WorkspaceService { INSERT INTO workspace_invitations (id, workspace_id, inviter_id, recipient_email, role, token, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, - ).run(id, workspaceId, inviterId, email, role, token, now.toISOString(), expiresAt); + ).run(id, workspaceId, inviterId, recipientUser.email, role, token, now.toISOString(), expiresAt); } // Create In-App Notification @@ -438,7 +445,7 @@ class WorkspaceService { }, ); - return { token, email, expiresAt, status: existingInvite ? 'updated' : 'created' }; + return { token, email: recipientUser.email, expiresAt, status: existingInvite ? 'updated' : 'created' }; } catch (error) { logger.error({ err: error }, 'Error creating invitation'); throw error; @@ -467,6 +474,11 @@ class WorkspaceService { * @param {string} email */ getUserInvitations(email) { + const normalizedEmail = normalizeEmail(email); + if (!normalizedEmail) { + return []; + } + return db .prepare( ` @@ -474,10 +486,10 @@ class WorkspaceService { FROM workspace_invitations wi JOIN workspaces w ON wi.workspace_id = w.id LEFT JOIN users u ON wi.inviter_id = u.id - WHERE wi.recipient_email = ? AND wi.status = 'pending' + WHERE lower(wi.recipient_email) = ? AND wi.status = 'pending' `, ) - .all(email); + .all(normalizedEmail); } /** @@ -519,17 +531,35 @@ class WorkspaceService { * @param {string} token * @param {string} userId */ + getInvitationActor(userId) { + return db.prepare('SELECT id, email FROM users WHERE id = ?').get(userId); + } + + ensureInvitationRecipient(invite, actor) { + if (!actor || !normalizeEmail(actor.email)) { + throw new Error('Authentication required'); + } + + if (normalizeEmail(invite.recipient_email) !== normalizeEmail(actor.email)) { + throw new Error('Invitation does not belong to authenticated user'); + } + } + acceptInvitation(token, userId) { const invite = this.getInvitation(token); if (!invite) throw new Error('Invalid invitation'); if (invite.expired) throw new Error('Invitation expired'); + const actor = this.getInvitationActor(userId); + this.ensureInvitationRecipient(invite, actor); const dbTx = db.transaction(() => { - // Add member - this.addMember(invite.workspace_id, userId, invite.role); + // Avoid duplicate member inserts while preserving existing member privileges. + if (!this.isMember(invite.workspace_id, userId)) { + this.addMember(invite.workspace_id, userId, invite.role); + } // Mark invite as accepted - db.prepare("UPDATE workspace_invitations SET status = 'accepted' WHERE id = ?").run(invite.id); + db.prepare("UPDATE workspace_invitations SET status = 'accepted' WHERE id = ? AND status = 'pending'").run(invite.id); }); dbTx(); @@ -540,11 +570,13 @@ class WorkspaceService { * Decline an invitation * @param {string} token */ - declineInvitation(token) { + declineInvitation(token, userId) { const invite = this.getInvitation(token); if (!invite) throw new Error('Invalid invitation'); + const actor = this.getInvitationActor(userId); + this.ensureInvitationRecipient(invite, actor); - db.prepare("UPDATE workspace_invitations SET status = 'declined' WHERE id = ?").run(invite.id); + db.prepare("UPDATE workspace_invitations SET status = 'declined' WHERE id = ? AND status = 'pending'").run(invite.id); return { id: invite.id, status: 'declined' }; } diff --git a/src/utils/corsOrigins.js b/src/utils/corsOrigins.js new file mode 100644 index 0000000..d2c43f6 --- /dev/null +++ b/src/utils/corsOrigins.js @@ -0,0 +1,26 @@ +const DEFAULT_DEV_ORIGINS = ['http://localhost:3000', 'http://processace.local:3000']; + +const parseCorsOrigins = (env = process.env) => { + const envOrigins = env.CORS_ALLOWED_ORIGINS; + + if (envOrigins) { + const parsedOrigins = envOrigins + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + + if (parsedOrigins.length > 0) { + return parsedOrigins; + } + } + + if (env.NODE_ENV === 'production') { + throw new Error('CORS_ALLOWED_ORIGINS must be set in production and contain at least one allowed origin.'); + } + + return DEFAULT_DEV_ORIGINS; +}; + +module.exports = { + parseCorsOrigins, +}; diff --git a/tests/integration/admin-users-pagination.test.js b/tests/integration/admin-users-pagination.test.js new file mode 100644 index 0000000..21a4eb3 --- /dev/null +++ b/tests/integration/admin-users-pagination.test.js @@ -0,0 +1,62 @@ +const { after, before, describe, it } = require('node:test'); +const assert = require('node:assert'); +const request = require('supertest'); + +process.env.DB_PATH = ':memory:'; +process.env.JWT_SECRET = 'test-jwt-secret'; +process.env.NODE_ENV = 'test'; +process.env.ENCRYPTION_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'; +process.env.LOG_LEVEL = 'silent'; + +const app = require('../../src/app'); + +describe('Admin users pagination integration tests', () => { + let server; + let adminAgent; + + const adminUser = { + name: 'Admin Pagination User', + email: `admin_users_pagination_${Date.now()}@example.com`, + password: 'Password123!', + }; + + before(async () => { + server = app.listen(0); + adminAgent = request.agent(server); + + await adminAgent.post('/api/auth/register').send(adminUser).expect(201); + await adminAgent.post('/api/auth/login').send({ email: adminUser.email, password: adminUser.password }).expect(200); + }); + + after(() => { + server.close(); + }); + + it('returns paginated users when page and limit are valid', async () => { + const res = await adminAgent.get('/api/admin/users?page=1&limit=100').expect(200); + + assert.ok(Array.isArray(res.body.users)); + assert.strictEqual(res.body.pagination.page, 1); + assert.strictEqual(res.body.pagination.limit, 100); + }); + + it('rejects page values lower than 1', async () => { + const res = await adminAgent.get('/api/admin/users?page=0').expect(400); + assert.strictEqual(res.body.error, 'Invalid pagination parameters: page must be a positive integer.'); + }); + + it('rejects non-numeric page values', async () => { + const res = await adminAgent.get('/api/admin/users?page=1abc').expect(400); + assert.strictEqual(res.body.error, 'Invalid pagination parameters: page must be a positive integer.'); + }); + + it('rejects limit values above the enforced maximum', async () => { + const res = await adminAgent.get('/api/admin/users?limit=101').expect(400); + assert.strictEqual(res.body.error, 'Invalid pagination parameters: limit must be between 1 and 100.'); + }); + + it('rejects non-numeric limit values', async () => { + const res = await adminAgent.get('/api/admin/users?limit=abc').expect(400); + assert.strictEqual(res.body.error, 'Invalid pagination parameters: limit must be between 1 and 100.'); + }); +}); diff --git a/tests/integration/app-security.test.js b/tests/integration/app-security.test.js index 3ef3b7d..ccacc0c 100644 --- a/tests/integration/app-security.test.js +++ b/tests/integration/app-security.test.js @@ -32,15 +32,15 @@ describe('App security integration tests', () => { assert.ok(!/script-src[^;]*'unsafe-inline'/.test(res.headers['content-security-policy'])); }); - it('injects the same nonce into dashboard importmap and module scripts', async () => { + it('serves dashboard HTML without nonce placeholders and with a nonce-bearing CSP header', async () => { const res = await request(server).get('/').expect(200); const nonceMatches = [...res.text.matchAll(/nonce="([^"]+)"/g)].map((match) => match[1]); - assert.ok(nonceMatches.length >= 2); - assert.strictEqual(new Set(nonceMatches).size, 1); - assert.match(res.text, / + +[click](javascript:alert(1)) + +`, + ); + + assert.ok(!/ **text**'); + + assert.ok(/<script>alert\(1\)<\/script>/.test(html)); + assert.ok(!/