-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/security remediation phased 2026 03 #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a92394c
5d8f3f4
3fcb787
38836f4
5c9b943
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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, | ||
| }; |
| 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)) { | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This rejects all mutating 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, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.