The multer filename kept path.extname(file.originalname), while the fileFilter only checked the spoofable client-supplied mimetype. An attacker could send Content-Type: image/png with originalname x.html, landing an .html file in /uploads that express.static serves as text/html — same-origin stored XSS. - Store the extension from a whitelist keyed by the accepted mimetype (MIME_EXT), never from originalname. The fileFilter uses the same map as its single source of truth, so only mimetypes with a safe mapped extension pass. - Use crypto.randomBytes for the random filename component. - Serve /uploads with an explicit X-Content-Type-Options: nosniff (defense in depth alongside helmet's global setting). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
4.2 KiB
JavaScript
100 lines
4.2 KiB
JavaScript
const express = require('express')
|
|
const path = require('path')
|
|
const fs = require('fs')
|
|
const cors = require('cors')
|
|
const helmet = require('helmet')
|
|
const morgan = require('morgan')
|
|
const cookieParser = require('cookie-parser')
|
|
require('dotenv').config()
|
|
|
|
const apiRouter = require('./router/api.router')
|
|
const createLogger = require('./utils/logger')
|
|
|
|
const httpLog = createLogger('http')
|
|
const errLog = createLogger('error')
|
|
|
|
const app = express()
|
|
|
|
// Behind Pangolin: trust the first proxy so req.secure (for the cookie flag),
|
|
// req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers.
|
|
app.set('trust proxy', 1)
|
|
|
|
// Security headers. CSP is left off here and will be tuned for the React SPA in
|
|
// the frontend phase; the rest of helmet's protections stay enabled.
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: false,
|
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
}),
|
|
)
|
|
|
|
// CORS only when a separate client origin is configured (local Vite dev). In
|
|
// production the SPA is same-origin, so no CORS is needed.
|
|
if (process.env.CLIENT_ORIGIN) {
|
|
app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true }))
|
|
}
|
|
|
|
// Access logs: real client IP (via trust proxy), the authenticated admin (if any),
|
|
// method, URL, status, response time, and size. Bodies/credentials are never logged.
|
|
morgan.token('user', (req) => (req.user && req.user.username) || '-')
|
|
const accessFormat =
|
|
':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes'
|
|
app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim()) } }))
|
|
|
|
app.use(express.json({ limit: '2mb' }))
|
|
app.use(cookieParser())
|
|
|
|
// ── Paths ─────────────────────────────────────────────────────────────
|
|
const SERVER_ROOT = path.join(__dirname, '..')
|
|
const REPO_ROOT = path.join(SERVER_ROOT, '..')
|
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
|
|
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
|
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
|
|
// Uploaded images — always served, even during maintenance. Force nosniff so a
|
|
// stored file is never interpreted as anything other than its declared type
|
|
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
|
|
// case that global config is ever changed).
|
|
app.use(
|
|
'/uploads',
|
|
express.static(UPLOAD_DIR, {
|
|
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
}),
|
|
)
|
|
|
|
// ── API ───────────────────────────────────────────────────────────────
|
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }))
|
|
app.use('/api', apiRouter)
|
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
|
|
|
// ── Client SPA ────────────────────────────────────────────────────────
|
|
// Serve the built React app if present; otherwise show a placeholder so the
|
|
// server is usable API-only before the frontend phase.
|
|
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
|
app.use(express.static(CLIENT_DIST))
|
|
app.get('*', (req, res) => res.sendFile(path.join(CLIENT_DIST, 'index.html')))
|
|
} else {
|
|
app.get('*', (req, res) =>
|
|
res
|
|
.type('html')
|
|
.send(
|
|
'<h1>UOMysticmoon API</h1><p>The web client has not been built yet. ' +
|
|
'The API is available under <code>/api/v1</code>.</p>',
|
|
),
|
|
)
|
|
}
|
|
|
|
// ── Error handler ─────────────────────────────────────────────────────
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
const status = err.status || (err.name === 'MulterError' ? 400 : 500)
|
|
// Log the stack for server faults; client (4xx) errors stay terse.
|
|
errLog.error(
|
|
`${req.method} ${req.originalUrl} -> ${status} ${err.message}`,
|
|
status >= 500 ? { stack: err.stack } : undefined,
|
|
)
|
|
res.status(status).json({ message: err.message || 'Internal Server Error' })
|
|
})
|
|
|
|
module.exports = app
|