Fix low-level security issues
Content-Security-Policy: add header with default-src 'self', unsafe-inline for styles (needed for JS-generated inline style attrs), and data: for embedded logo/signature images. JSON body limit: reduce from 10mb to 2mb (logo cap is 512KB base64). Session maxAge: now configurable via SESSION_MAX_AGE_HOURS env var (default 168h / 7 days). Documented in .env.example. Password strength: centralize validation in auth.js and raise the bar to 10+ characters with at least one letter and one non-letter. Applied consistently to all four password-setting paths (initial setup, login change-password, admin create user, admin edit user).
This commit is contained in:
@@ -2,5 +2,6 @@
|
|||||||
# Generate SESSION_SECRET with: openssl rand -hex 32
|
# Generate SESSION_SECRET with: openssl rand -hex 32
|
||||||
|
|
||||||
SESSION_SECRET=replace-with-a-random-64-character-hex-string
|
SESSION_SECRET=replace-with-a-random-64-character-hex-string
|
||||||
|
SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
|
||||||
PORT=3000
|
PORT=3000
|
||||||
DB_PATH=/app/data/check-printing.db
|
DB_PATH=/app/data/check-printing.db
|
||||||
|
|||||||
+8
-2
@@ -25,12 +25,14 @@ if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') {
|
|||||||
const SESSION_SECRET = process.env.SESSION_SECRET ||
|
const SESSION_SECRET = process.env.SESSION_SECRET ||
|
||||||
(() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions will reset on restart)'); return crypto.randomBytes(32).toString('hex'); })();
|
(() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions will reset on restart)'); return crypto.randomBytes(32).toString('hex'); })();
|
||||||
|
|
||||||
|
const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000;
|
||||||
|
|
||||||
app.use(session({
|
app.use(session({
|
||||||
store: new SessionStore(db),
|
store: new SessionStore(db),
|
||||||
secret: SESSION_SECRET,
|
secret: SESSION_SECRET,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days
|
cookie: { httpOnly: true, sameSite: 'strict', maxAge: SESSION_MAX_AGE_MS },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
@@ -38,10 +40,14 @@ app.use((req, res, next) => {
|
|||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
res.setHeader('Referrer-Policy', 'same-origin');
|
res.setHeader('Referrer-Policy', 'same-origin');
|
||||||
|
// style-src unsafe-inline: required for inline style= attrs in JS-generated HTML
|
||||||
|
// img-src data: required for base64-embedded logos and signatures
|
||||||
|
res.setHeader('Content-Security-Policy',
|
||||||
|
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'");
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '2mb' }));
|
||||||
app.use(express.static(path.join(__dirname, '../public')));
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
// ── Auth routes (public — no requireAuth) ─────────────────────────────────────
|
// ── Auth routes (public — no requireAuth) ─────────────────────────────────────
|
||||||
|
|||||||
+14
-2
@@ -5,6 +5,15 @@ const router = express.Router();
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
|
|
||||||
|
// ── Password validation ───────────────────────────────────────────────────────
|
||||||
|
// Returns an error string if invalid, or null if acceptable.
|
||||||
|
function validatePassword(password) {
|
||||||
|
if (!password || password.length < 10) return 'Password must be at least 10 characters.';
|
||||||
|
if (!/[a-zA-Z]/.test(password)) return 'Password must contain at least one letter.';
|
||||||
|
if (!/[^a-zA-Z]/.test(password)) return 'Password must contain at least one digit or symbol.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Login rate limiter ────────────────────────────────────────────────────────
|
// ── Login rate limiter ────────────────────────────────────────────────────────
|
||||||
// Tracks failed login attempts per IP. After 10 failures within 15 minutes,
|
// Tracks failed login attempts per IP. After 10 failures within 15 minutes,
|
||||||
// further attempts are blocked until the window resets.
|
// further attempts are blocked until the window resets.
|
||||||
@@ -57,7 +66,8 @@ router.post('/setup', async (req, res) => {
|
|||||||
|
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
const pwErr = validatePassword(password);
|
||||||
|
if (pwErr) return res.status(400).json({ error: pwErr });
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
@@ -121,7 +131,8 @@ router.post('/change-password', async (req, res) => {
|
|||||||
|
|
||||||
const { current_password, new_password } = req.body;
|
const { current_password, new_password } = req.body;
|
||||||
if (!current_password || !new_password) return res.status(400).json({ error: 'Both fields required.' });
|
if (!current_password || !new_password) return res.status(400).json({ error: 'Both fields required.' });
|
||||||
if (new_password.length < 8) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
const pwErr = validatePassword(new_password);
|
||||||
|
if (pwErr) return res.status(400).json({ error: pwErr });
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.session.userId);
|
||||||
const match = await bcrypt.compare(current_password, user.password_hash);
|
const match = await bcrypt.compare(current_password, user.password_hash);
|
||||||
@@ -135,3 +146,4 @@ router.post('/change-password', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.validatePassword = validatePassword;
|
||||||
|
|||||||
+5
-2
@@ -5,6 +5,7 @@ const router = express.Router();
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db/database');
|
const db = require('../db/database');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { validatePassword } = require('./auth');
|
||||||
|
|
||||||
// All /api/users routes require admin
|
// All /api/users routes require admin
|
||||||
router.use(requireAuth, requireAdmin);
|
router.use(requireAuth, requireAdmin);
|
||||||
@@ -30,7 +31,8 @@ router.post('/', async (req, res) => {
|
|||||||
const { username, password, role, accounts } = req.body;
|
const { username, password, role, accounts } = req.body;
|
||||||
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
|
||||||
if (!['admin', 'editor', 'viewer'].includes(role)) return res.status(400).json({ error: 'Invalid role.' });
|
if (!['admin', 'editor', 'viewer'].includes(role)) return res.status(400).json({ error: 'Invalid role.' });
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
const pwErr = validatePassword(password);
|
||||||
|
if (pwErr) return res.status(400).json({ error: pwErr });
|
||||||
|
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
@@ -80,7 +82,8 @@ router.put('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
const pwErr = validatePassword(password);
|
||||||
|
if (pwErr) return res.status(400).json({ error: pwErr });
|
||||||
const hash = await bcrypt.hash(password, 12);
|
const hash = await bcrypt.hash(password, 12);
|
||||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
|
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(hash, req.params.id);
|
.run(hash, req.params.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user