Merge pull request 'fix(security): add SPA CSP, drop x-powered-by, strengthen dedupe hash' (#84) from fix/security-headers-csp into main
Reviewed-on: #84 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Runic Gateway Website
|
# Runic Gateway Website
|
||||||
|
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
[](https://sonar.whitlocktech.com/dashboard?id=runic-gateway-website)
|
||||||
|
|
||||||
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
Public site, wiki, and protected admin panel for a private Ultima Online shard — a
|
||||||
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
full-stack app in one repo. Branding is instance-configurable via `BRAND_*` (see
|
||||||
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
[Branding](#branding)); **UOMysticmoon** is the first instance.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const internalRouter = require('./internal/internal.routes')
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// Internal-only listener, but don't advertise the stack anyway (defense in depth).
|
||||||
|
app.disable('x-powered-by')
|
||||||
|
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
app.get('/health', (req, res) => res.json({ status: 'ok' }))
|
||||||
|
|||||||
@@ -15,5 +15,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
// Don't inject the inline module-preload polyfill script — modern browsers all
|
||||||
|
// support modulepreload, and an inline <script> would violate the server's
|
||||||
|
// `script-src 'self'` CSP (see server/src/app.js). Keeps builds inline-free.
|
||||||
|
modulePreload: { polyfill: false },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -333,8 +333,9 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
|
|||||||
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
-- IDOC transitions, quests, skill.gain, fame/karma, audit.*, cheat.*, link.*,
|
||||||
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
-- server.*). High-frequency kinds (char.vitals, economy.supply) are NOT logged
|
||||||
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
-- here — they update shard_online / shard_economy instead, keeping the log lean.
|
||||||
-- dedupe_key = sha1(kind + t + stable-json(payload)); with the UNIQUE index it
|
-- dedupe_key = sha256(kind + t + stable-json(payload)) truncated to 40 hex chars
|
||||||
-- makes INSERT IGNORE idempotent so WS-reconnect backfill never double-inserts.
|
-- (fits CHAR(40)); with the UNIQUE index it makes INSERT IGNORE idempotent so
|
||||||
|
-- WS-reconnect backfill never double-inserts.
|
||||||
CREATE TABLE IF NOT EXISTS shard_events (
|
CREATE TABLE IF NOT EXISTS shard_events (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
kind VARCHAR(48) NOT NULL,
|
kind VARCHAR(48) NOT NULL,
|
||||||
|
|||||||
@@ -36,11 +36,38 @@ app.use(trustProxyDebug)
|
|||||||
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
// obvious scanner probes are 404'd immediately without reaching real handlers.
|
||||||
app.use(botScore.guard)
|
app.use(botScore.guard)
|
||||||
|
|
||||||
// Security headers. CSP is left off here and will be tuned for the React SPA in
|
// Security headers, including a Content-Security-Policy tuned for the built React
|
||||||
// the frontend phase; the rest of helmet's protections stay enabled.
|
// 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(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
contentSecurityPolicy: false,
|
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' },
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -123,7 +150,18 @@ try {
|
|||||||
// #swagger.ignore = true
|
// #swagger.ignore = true
|
||||||
res.json(swaggerSpec)
|
res.json(swaggerSpec)
|
||||||
})
|
})
|
||||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(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`,
|
customSiteTitle: `${brand.name} API docs`,
|
||||||
swaggerOptions: { persistAuthorization: true },
|
swaggerOptions: { persistAuthorization: true },
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ const internalRouter = require('./router/v1/internal/internal.routes')
|
|||||||
|
|
||||||
const internalApp = express()
|
const internalApp = express()
|
||||||
|
|
||||||
|
// Internal-only listener, but don't advertise the stack anyway (defense in depth).
|
||||||
|
internalApp.disable('x-powered-by')
|
||||||
|
|
||||||
internalApp.use(express.json())
|
internalApp.use(express.json())
|
||||||
|
|
||||||
// Liveness probe for this listener (no secret required); mirrors the bot's
|
// Liveness probe for this listener (no secret required); mirrors the bot's
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ function stableStringify(value) {
|
|||||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dedupe_key = sha1(kind + t + stable-json(payload)). Two identical events (same
|
// dedupe_key = sha256(kind + t + stable-json(payload)), truncated to 40 hex chars.
|
||||||
// kind, same timestamp, same body) collapse to one row.
|
// This is a content fingerprint for idempotent INSERT IGNORE, not a security value,
|
||||||
|
// but we use SHA-256 rather than SHA-1 anyway; the truncation keeps it inside the
|
||||||
|
// CHAR(40) column (160 bits is ample collision resistance for dedupe). Two identical
|
||||||
|
// events (same kind, same timestamp, same body) collapse to one row.
|
||||||
function dedupeKey(kind, t, payload) {
|
function dedupeKey(kind, t, payload) {
|
||||||
return crypto.createHash('sha1').update(`${kind}|${t}|${stableStringify(payload)}`).digest('hex')
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${kind}|${t}|${stableStringify(payload)}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append one event. Returns true if a new row was inserted (false = deduped).
|
// Append one event. Returns true if a new row was inserted (false = deduped).
|
||||||
|
|||||||
Reference in New Issue
Block a user