Files
website/server/src/app.js
wtclaude 9b74999610
All checks were successful
PR Checks / bot-install (pull_request) Successful in 16s
PR Checks / server-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 9m15s
feat(security): soak the tightened CSP on report-only, with a same-origin sink
Phase 1 of docs/website/API_V2_PLAN.md. The tightened policy ships on
Content-Security-Policy-Report-Only alongside the unchanged enforced one for a
release; a follow-up PR flips it after the soak comes back clean.

The plan expected a two-directive delta. It is one. `form-action 'self'` was
described as absent because it is not in the directives object in app.js — but
the middleware runs with `useDefaults: true` and helmet's defaults already
supply it, so the header served in production has carried it all along. Caught
by capturing the live header from the running app instead of reading the config.
It is now written out explicitly in config/csp.js regardless: a security
directive should not depend on a third-party library's default surviving its
next major version. The enforced header's contents do not change at all, and a
test pins it verbatim.

So the whole behavioural delta is `frame-ancestors 'self'` -> `'none'`. That is
still the directive most worth soaking: a frame-ancestors report is generated by
the browser of whoever framed the site, which is the only way to find out that
something legitimately embeds us before an enforcing policy breaks it.

The policies move to config/csp.js, with the report-only one derived by spread
from the enforced one so the two cannot drift and the object reads as a diff.

`report-to` needs somewhere to point, so this adds POST /api/csp-report --
same-origin on purpose, since reports describe attacks against this site and
should not go to a third-party collector. It is mounted outside /api/v1 next to
/api/health: the browser learns the path from the policy header, never from a
client build, so it is not versioned client contract.

It is necessarily unauthenticated -- browsers send reports with no session, and
gating it would silence exactly the anonymous visitors worth hearing about -- so
it is bounded on every axis:

  * both wire formats, since report-uri (Firefox/Safari) sends hyphenated keys
    in application/csp-report and report-to (Chrome) sends camelCase envelopes
    in application/reports+json; handling one silently drops half the browsers,
  * report-to also needs the Reporting-Endpoints response header or it is inert,
  * 16 KB body cap, per-IP rate limit, fixed field allowlist, every logged field
    truncated (script-sample is attacker-influenced and can carry a whole inline
    script),
  * always 204, even for malformed input: a 4xx would reach the global error
    handler, which logs the offending body -- turning an open endpoint into a
    log-flood primitive.

Nothing is persisted; reports go to the `csp` log tag.

routes.manifest.json moves 199 -> 200, which is the freeze from PR 0 working as
designed: the one new URL is visible as a reviewed +1 rather than slipping
through. Swagger regenerated to match.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 15:19:26 -05:00

234 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 cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand')
const csp = require('./config/csp')
const { cspReportLimiter } = require('./middleware/rateLimit')
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. The policies themselves (and the reasoning behind every non-'self' allowance)
// live in config/csp.js. The interactive API docs at /api/docs get their own looser
// policy below.
app.use(
helmet({
contentSecurityPolicy: { useDefaults: true, directives: csp.enforced },
crossOriginResourcePolicy: { policy: 'cross-origin' },
}),
)
// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one
// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1).
// Both headers are served at once on purpose: the live policy keeps protecting users
// while anything the tightened version would have broken shows up as a report at
// /api/csp-report instead of as a broken page. Reports are same-origin — they
// describe attacks on this site and are not handed to a third party.
app.use(csp.reportingEndpoints)
app.use(
helmet.contentSecurityPolicy({
useDefaults: true,
reportOnly: true,
directives: csp.reportOnly,
}),
)
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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' }),
)
// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is
// not part of the versioned client contract — it exists for the browser, which learns
// the path from the policy header, never from a client build.
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)
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