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(!/