diff --git a/.env.example b/.env.example index b91232b..df9ad58 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,6 @@ # Generate SESSION_SECRET with: openssl rand -hex 32 SESSION_SECRET=replace-with-a-random-64-character-hex-string +SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days) PORT=3000 DB_PATH=/app/data/check-printing.db diff --git a/src/app.js b/src/app.js index c945cd5..16139ee 100644 --- a/src/app.js +++ b/src/app.js @@ -25,12 +25,14 @@ if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') { 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'); })(); +const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000; + app.use(session({ store: new SessionStore(db), secret: SESSION_SECRET, resave: 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 @@ -38,10 +40,14 @@ app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); 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(); }); -app.use(express.json({ limit: '10mb' })); +app.use(express.json({ limit: '2mb' })); app.use(express.static(path.join(__dirname, '../public'))); // ── Auth routes (public — no requireAuth) ───────────────────────────────────── diff --git a/src/routes/auth.js b/src/routes/auth.js index 35f4b4c..55e9026 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -5,6 +5,15 @@ const router = express.Router(); const bcrypt = require('bcryptjs'); 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 ──────────────────────────────────────────────────────── // Tracks failed login attempts per IP. After 10 failures within 15 minutes, // further attempts are blocked until the window resets. @@ -57,7 +66,8 @@ router.post('/setup', async (req, res) => { const { username, password } = req.body; 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 result = db.prepare( @@ -121,7 +131,8 @@ router.post('/change-password', async (req, res) => { const { current_password, new_password } = req.body; 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 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.validatePassword = validatePassword; diff --git a/src/routes/users.js b/src/routes/users.js index 6c55998..b37a636 100644 --- a/src/routes/users.js +++ b/src/routes/users.js @@ -5,6 +5,7 @@ const router = express.Router(); const bcrypt = require('bcryptjs'); const db = require('../db/database'); const { requireAuth, requireAdmin } = require('../middleware/auth'); +const { validatePassword } = require('./auth'); // All /api/users routes require admin router.use(requireAuth, requireAdmin); @@ -30,7 +31,8 @@ router.post('/', async (req, res) => { const { username, password, role, accounts } = req.body; 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 (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); @@ -80,7 +82,8 @@ router.put('/:id', async (req, res) => { } 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); db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") .run(hash, req.params.id);