Address SonarQube security hotspots on the website: - server/src/app.js: replace `contentSecurityPolicy: false` with a helmet CSP tuned for the built React SPA (script-src 'self'; style-src adds 'unsafe-inline' for React inline styles + the Google Fonts stylesheet; font-src gstatic; img-src allows data:/https: for uploads, embedded body images and BRAND_* assets; connect-src 'self' for REST+SSE). upgrade-insecure-requests is intentionally omitted (TLS terminates at the proxy; keeps local `npm start` over http working). The /api/docs Swagger UI route gets a scoped looser policy (inline script/style) since swagger-ui-express injects an inline bootstrap. - client/vite.config.js: disable the inline module-preload polyfill so code-split builds keep `script-src 'self'` valid (RichTextEditor is a separate chunk). - bot/src/app.js, server/src/internalApp.js: disable x-powered-by on the two internal-only listeners (the public app already strips it via helmet). - shardEvents dedupe key: SHA-1 -> SHA-256 truncated to 40 hex chars (fits the existing CHAR(40) column, no migration; it is a content fingerprint, not a security value). schema.sql comment updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
236 lines
11 KiB
JavaScript
236 lines
11 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 swaggerUi = require('swagger-ui-express')
|
|
|
|
const apiRouter = require('./router/api.router')
|
|
const wellKnown = require('./router/wellKnown.controller')
|
|
const brand = require('./config/brand')
|
|
const createLogger = require('./utils/logger')
|
|
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
|
|
const botScore = require('./middleware/botScore')
|
|
|
|
const httpLog = createLogger('http')
|
|
const errLog = createLogger('error')
|
|
|
|
const app = express()
|
|
|
|
// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
|
|
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
|
|
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
|
|
// never a blanket `true` (which would let clients spoof their IP). Must run
|
|
// before any middleware that reads req.ip.
|
|
applyTrustProxy(app)
|
|
|
|
// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
|
|
// the bot guard so it logs scanner/junk source IPs too.
|
|
app.use(trustProxyDebug)
|
|
|
|
// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
|
|
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
|
app.use(botScore.guard)
|
|
|
|
// Security headers, including a Content-Security-Policy tuned for the built React
|
|
// SPA. Notes on each non-'self' allowance:
|
|
// • style-src 'unsafe-inline' — React renders pervasive inline `style={{…}}`
|
|
// attributes, and CSP style *attributes* cannot be nonce'd; this is required.
|
|
// Also whitelists the Google Fonts stylesheet host.
|
|
// • font-src — Google Fonts (Cinzel) serves the font files from gstatic.
|
|
// • img-src https:/data: — uploaded images are same-origin, but wiki/news bodies
|
|
// (sanitizeHtml allows <img> over http/https) and BRAND_* logo/hero/favicon may
|
|
// point at external https images. http images are blocked by mixed-content on
|
|
// the https site anyway.
|
|
// • connect-src 'self' — the REST API and SSE streams are same-origin.
|
|
// • upgrade-insecure-requests is intentionally dropped: TLS is terminated at the
|
|
// proxy, there are no mixed-content subresources to upgrade, and leaving it on
|
|
// breaks a local `npm start` served over plain http.
|
|
// The interactive API docs at /api/docs get their own looser policy below.
|
|
app.use(
|
|
helmet({
|
|
contentSecurityPolicy: {
|
|
useDefaults: true,
|
|
directives: {
|
|
'default-src': ["'self'"],
|
|
'script-src': ["'self'"],
|
|
'style-src': ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
|
|
'font-src': ["'self'", 'https://fonts.gstatic.com'],
|
|
'img-src': ["'self'", 'data:', 'https:'],
|
|
'connect-src': ["'self'"],
|
|
'frame-ancestors': ["'self'"],
|
|
'object-src': ["'none'"],
|
|
'base-uri': ["'self'"],
|
|
'upgrade-insecure-requests': null,
|
|
},
|
|
},
|
|
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')
|
|
const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand')
|
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
|
|
// Escape user/brand text for safe interpolation into the HTML shell.
|
|
const htmlEscape = (s) =>
|
|
String(s).replace(
|
|
/[&<>"']/g,
|
|
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
|
)
|
|
|
|
// Template the built index.html <head> with instance branding (title, meta
|
|
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
|
|
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
|
|
function renderIndexHtml(html) {
|
|
const title = htmlEscape(brand.name)
|
|
const desc = htmlEscape(brand.description)
|
|
const tags = [
|
|
`<meta property="og:title" content="${title}" />`,
|
|
`<meta property="og:description" content="${desc}" />`,
|
|
'<meta property="og:type" content="website" />',
|
|
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
|
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
|
'<meta name="twitter:card" content="summary_large_image" />',
|
|
`<meta name="twitter:title" content="${title}" />`,
|
|
`<meta name="twitter:description" content="${desc}" />`,
|
|
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n ')
|
|
return html
|
|
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
|
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
|
.replace(/<\/head>/i, ` ${tags}\n </head>`)
|
|
}
|
|
|
|
// 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 docs (Swagger UI) ─────────────────────────────────────────────
|
|
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
|
|
// is generated from route annotations by `npm run swagger` (server/swagger/).
|
|
// Loaded lazily and guarded so a missing spec never crashes the server.
|
|
try {
|
|
// eslint-disable-next-line global-require
|
|
const swaggerSpec = require('../swagger/swagger-output.json')
|
|
app.get('/api/docs.json', (req, res) => {
|
|
// #swagger.ignore = true
|
|
res.json(swaggerSpec)
|
|
})
|
|
// swagger-ui-express injects an inline bootstrap script and inline styles, which
|
|
// the global 'self'-only script-src would block — relax CSP for this route only.
|
|
const swaggerCsp = helmet.contentSecurityPolicy({
|
|
useDefaults: true,
|
|
directives: {
|
|
'script-src': ["'self'", "'unsafe-inline'"],
|
|
'style-src': ["'self'", "'unsafe-inline'"],
|
|
'img-src': ["'self'", 'data:', 'https:'],
|
|
'upgrade-insecure-requests': null,
|
|
},
|
|
})
|
|
app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
|
customSiteTitle: `${brand.name} API docs`,
|
|
swaggerOptions: { persistAuthorization: true },
|
|
}))
|
|
} catch (err) {
|
|
errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
|
|
message: err.message,
|
|
})
|
|
}
|
|
|
|
// ── API ───────────────────────────────────────────────────────────────
|
|
app.get(
|
|
'/api/health',
|
|
// #swagger.tags = ['Health']
|
|
// #swagger.summary = 'Liveness probe'
|
|
/* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
|
|
(req, res) => res.json({ status: 'ok' }),
|
|
)
|
|
app.use('/api', apiRouter)
|
|
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
|
|
|
|
// ── /.well-known ──────────────────────────────────────────────────────
|
|
// Android App Links verification file at the web root (M9 follow-up). Mounted
|
|
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
|
|
// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md).
|
|
app.get('/.well-known/assetlinks.json', wellKnown.assetlinks)
|
|
|
|
// ── Client SPA ────────────────────────────────────────────────────────
|
|
// Serve the built React app if present; otherwise show a placeholder so the
|
|
// server is usable API-only before the frontend phase.
|
|
// Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_*
|
|
// paths point at /brand/*. Optional — the defaults live under the SPA's /assets,
|
|
// so this only matters for a custom mount.
|
|
if (fs.existsSync(BRAND_DIR)) {
|
|
app.use(
|
|
'/brand',
|
|
express.static(BRAND_DIR, {
|
|
setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
|
|
}),
|
|
)
|
|
}
|
|
|
|
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
|
|
// Serve a branded copy of the index.html shell for every SPA route; assets keep
|
|
// their own cache-friendly static handler.
|
|
const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
|
|
app.use(express.static(CLIENT_DIST, { index: false }))
|
|
app.get('*', (req, res) => res.type('html').send(indexHtml))
|
|
} else {
|
|
app.get('*', (req, res) =>
|
|
res
|
|
.type('html')
|
|
.send(
|
|
`<h1>${htmlEscape(brand.name)} 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
|