93 lines
3.4 KiB
JavaScript
93 lines
3.4 KiB
JavaScript
import express from 'express'
|
|
import cors from 'cors'
|
|
import helmet from 'helmet'
|
|
import http from 'node:http'
|
|
import 'dotenv/config'
|
|
import { auditRouter } from './routes/audit.js'
|
|
import { env } from './services/env.js'
|
|
|
|
const app = express()
|
|
|
|
// Behind a reverse proxy, so trust exactly one proxy hop. Without this the
|
|
// rate limiter would see the proxy's IP for everyone and throttle all
|
|
// visitors together.
|
|
app.set('trust proxy', 1)
|
|
|
|
app.use(helmet())
|
|
|
|
// An allowlist, never '*'. ALLOWED_ORIGINS is comma separated.
|
|
const allowedOrigins = (env('ALLOWED_ORIGINS', '') ?? '')
|
|
.split(',')
|
|
.map((origin) => origin.trim().replace(/\/$/, ''))
|
|
.filter(Boolean)
|
|
|
|
// An empty allowlist refuses every browser request, which looks exactly like
|
|
// a broken endpoint. Say so at startup instead of at 3am.
|
|
if (allowedOrigins.length === 0) {
|
|
console.warn(
|
|
'WARNING: ALLOWED_ORIGINS is empty - all cross-origin requests will be refused.'
|
|
)
|
|
}
|
|
|
|
app.use(
|
|
cors({
|
|
origin(origin, callback) {
|
|
// Same-origin and curl send no Origin header at all.
|
|
if (!origin) return callback(null, true)
|
|
if (allowedOrigins.includes(origin)) return callback(null, true)
|
|
const error = new Error('Origin not allowed')
|
|
// Read by the error handler below - a rejected origin is expected,
|
|
// routine traffic (bots, scanners, this exact curl test), not a
|
|
// server fault, and should neither look like one nor be logged as one.
|
|
error.status = 403
|
|
callback(error)
|
|
},
|
|
methods: ['POST'],
|
|
allowedHeaders: ['Content-Type'],
|
|
})
|
|
)
|
|
|
|
// The form payload is a few hundred bytes. Anything near this cap is abuse.
|
|
app.use(express.json({ limit: '10kb' }))
|
|
|
|
app.get('/health', (_req, res) => res.json({ status: 'ok' }))
|
|
|
|
app.use('/api/audit-request', auditRouter)
|
|
|
|
// Keep internals out of the response body.
|
|
app.use((error, _req, res, _next) => {
|
|
const status = error.status ?? 500
|
|
if (status >= 500) {
|
|
console.error('Unhandled error:', error.message)
|
|
res.status(500).json({ success: false, message: 'Internal server error.' })
|
|
return
|
|
}
|
|
// Expected rejections (e.g. disallowed CORS origin) - not a server fault,
|
|
// so no error-level log line and no generic message.
|
|
res.status(status).json({ success: false, message: error.message })
|
|
})
|
|
|
|
const port = Number(env('PORT', '3001'))
|
|
|
|
app.listen(port, '127.0.0.1', () => {
|
|
console.log(`Audit endpoint listening on 127.0.0.1:${port}`)
|
|
})
|
|
|
|
// Optional second listener for a reverse proxy that itself runs in Docker
|
|
// (e.g. Caddy). 127.0.0.1 inside that proxy's container is the container's
|
|
// own loopback, not this host's, so it cannot reach the 127.0.0.1 listener
|
|
// above at all. The fix is NOT to bind 0.0.0.0 - on a VPS with a public IP
|
|
// and no confirmed firewall, that would also accept connections arriving on
|
|
// the public interface, bypassing the proxy's TLS termination entirely.
|
|
// Binding this second, additional listener to the Docker bridge's gateway
|
|
// address instead only accepts traffic arriving over that specific virtual
|
|
// network. Find the right value with:
|
|
// docker inspect <proxy-container> --format \
|
|
// '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}: {{$v.Gateway}}{{"\n"}}{{end}}'
|
|
const dockerBridgeHost = env('DOCKER_BRIDGE_HOST')
|
|
if (dockerBridgeHost) {
|
|
http.createServer(app).listen(port, dockerBridgeHost, () => {
|
|
console.log(`Audit endpoint also listening on ${dockerBridgeHost}:${port} (Docker bridge)`)
|
|
})
|
|
}
|