Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
28 changes: 26 additions & 2 deletions src/api/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 = {
Expand Down Expand Up @@ -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' });
}
Expand Down
8 changes: 5 additions & 3 deletions src/api/evidence.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 : {};
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 8 additions & 2 deletions src/api/invitations.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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 });
}
Expand Down
26 changes: 4 additions & 22 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down
10 changes: 10 additions & 0 deletions src/config/storagePaths.js
Original file line number Diff line number Diff line change
@@ -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,
};
94 changes: 94 additions & 0 deletions src/middleware/csrf.js
Original file line number Diff line number Diff line change
@@ -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)) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize exempt auth paths before CSRF path matching

CSRF exemptions are checked with exact string equality, so /api/auth/login/ and /api/auth/register/ are not exempt even though the app does not enable strict routing and Express will route those trailing-slash URLs to the same handlers. In that case, login/registration requests are incorrectly forced through CSRF token checks and can fail with 403, which is an avoidable regression for clients/frameworks that append trailing slashes.

Useful? React with 👍 / 👎.

return next();
}

const requestOrigin = resolveRequestOrigin(req);
const allowedOrigins = getAllowedOrigins(req);

if (!requestOrigin || !allowedOrigins.has(requestOrigin)) {
return res.status(403).json({ error: 'Invalid request origin' });
Comment on lines +75 to +76

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Permit CSRF validation when Origin header is absent

This rejects all mutating /api requests that lack both Origin and Referer, even when the request includes a valid auth cookie and matching x-csrf-token. Non-browser clients (CLI scripts, server-side automation, webhook relays) commonly omit those headers, so this change introduces a backwards-incompatible 403 path for previously valid API calls. At minimum, this should be configurable or allow token-matched requests when origin metadata is unavailable.

Useful? React with 👍 / 👎.

}

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,
};
2 changes: 2 additions & 0 deletions src/public/admin-jobs.html
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,10 @@ <h3 id="artifactModalTitle" data-i18n="adminJobs.artifactView">Artifact View</h3

<script src="https://unpkg.com/bpmn-js@18.13.1/dist/bpmn-modeler.development.js"></script>
<script src="https://unpkg.com/marked@12.0.0/marked.min.js"></script>
<script src="https://unpkg.com/dompurify@3.2.7/dist/purify.min.js"></script>
<script src="https://unpkg.com/easymde@2.18.0/dist/easymde.min.js"></script>

<script src="js/utils/markdown-sanitizer.js"></script>
<script src="js/api-client.js"></script>
<script src="js/i18n.js"></script>
<script src="js/app-info.js"></script>
Expand Down
2 changes: 2 additions & 0 deletions src/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ <h3 data-i18n="dashboard.createWorkspace">Create Workspace</h3>

<script src="https://unpkg.com/bpmn-js@18.13.1/dist/bpmn-modeler.development.js"></script>
<script src="https://unpkg.com/marked@12.0.0/marked.min.js"></script>
<script src="https://unpkg.com/dompurify@3.2.7/dist/purify.min.js"></script>
<script src="https://unpkg.com/easymde@2.18.0/dist/easymde.min.js"></script>

<script src="js/utils/markdown-sanitizer.js"></script>
<script src="js/api-client.js"></script>
<script src="js/i18n.js"></script>
<script src="js/app-info.js"></script>
Expand Down
6 changes: 3 additions & 3 deletions src/public/js/admin-jobs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = '<p class="text-error">Error: Marked library not loaded.</p>';
return;
}
artifactModalBody.innerHTML = `
<div id="markdown-content" class="markdown-content">${marked.parse(content)}</div>
<div id="markdown-content" class="markdown-content">${window.renderSafeMarkdown(content)}</div>
`;
} else {
setModalClasses(false);
Expand Down
Loading
Loading