mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
4e5ba17710
* refactor(backend): sanitize user input before logging to close CRLF injection
Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.
Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string
The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.
* refactor(backend): printf-style format strings for tainted-log call sites
CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.
Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).
No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.
* fix(backend): wrap nodeId/snapshotId in fleet restore debug log
CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
233 lines
8.8 KiB
TypeScript
233 lines
8.8 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import bcrypt from 'bcrypt';
|
|
import crypto from 'crypto';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import {
|
|
authMiddleware,
|
|
issueSessionCookie,
|
|
issueMfaPendingCookie,
|
|
clearMfaPendingCookie,
|
|
reissueSessionAfterTokenBump,
|
|
} from '../middleware/auth';
|
|
import { authRateLimiter } from '../middleware/rateLimiters';
|
|
import { requireAdmin } from '../middleware/tierGates';
|
|
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
import {
|
|
BCRYPT_SALT_ROUNDS,
|
|
COOKIE_NAME,
|
|
MFA_PENDING_COOKIE_NAME,
|
|
MFA_PENDING_SCOPE,
|
|
MIN_PASSWORD_LENGTH,
|
|
} from '../helpers/constants';
|
|
import { isSecureRequest } from '../helpers/cookies';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
export const authRouter = Router();
|
|
|
|
// Check if setup is needed, and whether the caller currently holds a valid
|
|
// `mfa_pending` partial-auth cookie (so the frontend can route to the
|
|
// challenge screen on a page reload mid-flow, e.g. after an OIDC redirect).
|
|
authRouter.get('/status', async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
|
|
|
let mfaPending = false;
|
|
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
|
if (mfaCookie && settings.auth_jwt_secret) {
|
|
try {
|
|
const decoded = jwt.verify(mfaCookie, settings.auth_jwt_secret) as { scope?: string };
|
|
mfaPending = decoded.scope === MFA_PENDING_SCOPE;
|
|
} catch {
|
|
// Expired or invalid cookie; treat as no pending challenge.
|
|
}
|
|
}
|
|
|
|
res.json({ needsSetup, mfaPending });
|
|
} catch (error) {
|
|
console.error('Error checking setup status:', error);
|
|
res.json({ needsSetup: true, mfaPending: false });
|
|
}
|
|
});
|
|
|
|
// Initial setup endpoint
|
|
authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const dbSvc = DatabaseService.getInstance();
|
|
const settings = dbSvc.getGlobalSettings();
|
|
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
|
if (!needsSetup) {
|
|
res.status(400).json({ error: 'Setup has already been completed' });
|
|
return;
|
|
}
|
|
|
|
const { username, password, confirmPassword } = req.body;
|
|
|
|
if (!username || !password || !confirmPassword) {
|
|
res.status(400).json({ error: 'All fields are required' });
|
|
return;
|
|
}
|
|
|
|
if (username.length < 3) {
|
|
res.status(400).json({ error: 'Username must be at least 3 characters' });
|
|
return;
|
|
}
|
|
|
|
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
|
return;
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
res.status(400).json({ error: 'Passwords do not match' });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
|
const jwtSecret = crypto.randomBytes(64).toString('hex');
|
|
dbSvc.updateGlobalSetting('auth_username', username);
|
|
dbSvc.updateGlobalSetting('auth_password_hash', passwordHash);
|
|
dbSvc.updateGlobalSetting('auth_jwt_secret', jwtSecret);
|
|
|
|
// Create admin user in users table
|
|
dbSvc.addUser({ username, password_hash: passwordHash, role: 'admin' });
|
|
|
|
issueSessionCookie(res, req, { username, role: 'admin', token_version: 1 }, jwtSecret);
|
|
res.json({ success: true, message: 'Setup completed successfully' });
|
|
} catch (error) {
|
|
console.error('Setup error:', error);
|
|
res.status(500).json({ error: 'Failed to complete setup' });
|
|
}
|
|
});
|
|
|
|
// Login endpoint
|
|
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
res.status(400).json({ error: 'Username and password are required' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const db = DatabaseService.getInstance();
|
|
const user = db.getUserByUsername(username);
|
|
|
|
if (user) {
|
|
const isValid = await bcrypt.compare(password, user.password_hash);
|
|
if (isValid) {
|
|
const settings = db.getGlobalSettings();
|
|
const jwtSecret = settings.auth_jwt_secret;
|
|
if (!jwtSecret) throw new Error('JWT secret missing from DB');
|
|
|
|
// If MFA is enabled for this user, issue only the partial-auth cookie
|
|
// and signal the client to complete the TOTP challenge. No session
|
|
// cookie is set until the second factor is verified.
|
|
const mfa = db.getUserMfa(user.id);
|
|
if (isDebugEnabled()) {
|
|
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
|
|
}
|
|
if (mfa?.enabled) {
|
|
issueMfaPendingCookie(res, req, user, jwtSecret);
|
|
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
|
|
res.json({ success: true, mfaRequired: true });
|
|
return;
|
|
}
|
|
|
|
issueSessionCookie(res, req, user, jwtSecret);
|
|
console.log('[Auth] Login successful:', user.username);
|
|
res.json({ success: true, message: 'Login successful' });
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.warn('[Auth] Login failed for username:', sanitizeForLog(username));
|
|
res.status(401).json({ error: 'Invalid credentials' });
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
res.status(500).json({ error: 'Login failed' });
|
|
}
|
|
});
|
|
|
|
// Any authenticated user can change their own password.
|
|
authRouter.put('/password', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (rejectApiTokenScope(req, res, 'API tokens cannot change passwords.')) return;
|
|
try {
|
|
const { oldPassword, newPassword } = req.body;
|
|
if (!oldPassword || !newPassword) {
|
|
res.status(400).json({ error: 'Old password and new password are required' });
|
|
return;
|
|
}
|
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
|
|
return;
|
|
}
|
|
|
|
const dbSvc = DatabaseService.getInstance();
|
|
const user = dbSvc.getUserByUsername(req.user!.username);
|
|
|
|
if (!user) {
|
|
res.status(400).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
const isValid = await bcrypt.compare(oldPassword, user.password_hash);
|
|
if (!isValid) {
|
|
res.status(401).json({ error: 'Invalid old password' });
|
|
return;
|
|
}
|
|
|
|
const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
|
|
dbSvc.updateUser(user.id, { password_hash: newHash });
|
|
// Keep global_settings in sync for backward compat.
|
|
dbSvc.updateGlobalSetting('auth_password_hash', newHash);
|
|
// Invalidate all other sessions for this user, then re-issue the caller's
|
|
// cookie so the current session survives the token-version bump.
|
|
dbSvc.bumpTokenVersion(user.id);
|
|
reissueSessionAfterTokenBump(req, res, user.id);
|
|
console.log('[Auth] Password changed by:', req.user!.username);
|
|
res.json({ success: true, message: 'Password updated successfully' });
|
|
} catch (error) {
|
|
console.error('[Auth] Password update error:', error);
|
|
res.status(500).json({ error: 'Failed to update password' });
|
|
}
|
|
});
|
|
|
|
authRouter.post('/logout', (req: Request, res: Response): void => {
|
|
res.clearCookie(COOKIE_NAME, {
|
|
httpOnly: true,
|
|
secure: isSecureRequest(req),
|
|
sameSite: 'strict',
|
|
});
|
|
// Clear any partial-auth cookie so a user aborting the MFA challenge is
|
|
// returned to a fully unauthenticated state.
|
|
clearMfaPendingCookie(res, req);
|
|
res.json({ success: true, message: 'Logged out successfully' });
|
|
});
|
|
|
|
authRouter.get('/check', authMiddleware, (req: Request, res: Response): void => {
|
|
res.json({ authenticated: true, user: req.user });
|
|
});
|
|
|
|
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication.
|
|
authRouter.post('/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (rejectApiTokenScope(req, res, 'API tokens cannot generate node tokens.')) return;
|
|
try {
|
|
const settings = DatabaseService.getInstance().getGlobalSettings();
|
|
const jwtSecret = settings.auth_jwt_secret;
|
|
if (!jwtSecret) {
|
|
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
|
|
return;
|
|
}
|
|
// Default 1-year expiry; admin should rotate tokens periodically.
|
|
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret, { expiresIn: '365d' });
|
|
res.json({ token });
|
|
} catch (error) {
|
|
res.status(500).json({ error: getErrorMessage(error, 'Failed to generate node token') });
|
|
}
|
|
});
|