Harden web root, secrets and realtime auth

- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and
  app internals; security headers, HSTS over TLS, optional HTTPS redirect
- uploads served through app/file.php to signed-in users only
- Apache/PHP hardening config for the container (ServerTokens, expose_php)
- least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php;
  SMTP passwords re-encrypted with a random IV (secret_box.php)
- Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets
- escape notification text; CLI guards on build scripts; no fixed demo password
This commit is contained in:
Thanakorn
2026-09-24 14:53:40 +07:00
parent e579dd596c
commit ae98dcdcdd
29 changed files with 525 additions and 42 deletions
+7
View File
@@ -8,6 +8,13 @@ const PHP_WEBROOT = process.env.PHP_WEBROOT || '/brnwms/app';
const SECRET = process.env.EMIT_SECRET || '';
const NODE_PORT = process.env.PORT || 3000;
if (!SECRET) {
// The PHP cron endpoints refuse an empty secret; fail loudly instead of
// sending unauthenticated calls every schedule tick.
console.error('EMIT_SECRET is not set — scheduler not started.');
process.exit(1);
}
function callPhp(path, body) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(body);
+49 -18
View File
@@ -1,9 +1,42 @@
require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const EMIT_SECRET = process.env.EMIT_SECRET || '';
if (!EMIT_SECRET) {
// Without a secret anyone could call /emit and sign socket tokens.
console.error('EMIT_SECRET is not set — refusing to start.');
process.exit(1);
}
// Socket tokens are signed with a key derived from EMIT_SECRET, so a token can
// never double as the /emit secret. Must match socket_token() in include_ending.php.
const SOCKET_KEY = crypto.createHmac('sha256', EMIT_SECRET).update('socket-token').digest();
function safeEqual(a, b) {
const x = Buffer.from(String(a));
const y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
// Token = base64url(JSON {c, u, r, exp}) + "." + base64url(HMAC-SHA256(payload)).
// Returns the claims, or null when the token is missing, forged or expired.
function verifySocketToken(token) {
if (typeof token !== 'string' || token.indexOf('.') < 1) return null;
const [payload, sig] = token.split('.', 2);
const expected = crypto.createHmac('sha256', SOCKET_KEY).update(payload).digest('base64url');
if (!safeEqual(sig, expected)) return null;
let claims;
try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); }
catch (e) { return null; }
if (!claims || !Number.isInteger(claims.c) || claims.c <= 0) return null;
if (!Number.isInteger(claims.exp) || claims.exp < Math.floor(Date.now() / 1000)) return null;
return claims;
}
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
@@ -17,8 +50,8 @@ app.use(express.json());
// Body: { event, data, company_id }
//
app.post('/emit', (req, res) => {
const secret = req.headers['x-emit-secret'];
if (secret !== process.env.EMIT_SECRET) {
const secret = req.headers['x-emit-secret'] || '';
if (!safeEqual(secret, EMIT_SECRET)) {
return res.status(403).json({ ok: false, message: 'Forbidden' });
}
@@ -46,28 +79,26 @@ app.post('/emit', (req, res) => {
});
// ── /health ───────────────────────────────────────────────────────────────────
// Deliberately public (used by uptime checks); reports status only.
app.get('/health', (req, res) => {
res.json({
status: 'ok',
uptime: Math.floor(process.uptime()),
connections: io.engine.clientsCount,
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10
});
res.json({ status: 'ok' });
});
// ── WebSocket connections ─────────────────────────────────────────────────────
// Each browser tab connects here on page load.
// It joins a room named company_<id> so events stay isolated per company.
// Each browser tab connects here on page load with a token PHP signed for the
// signed-in user (include_ending.php). Rooms come only from the verified token —
// never from values the browser chooses — so a visitor cannot listen to another
// company's events.
//
io.on('connection', (socket) => {
const company_id = socket.handshake.query.company_id;
const user_id = socket.handshake.query.user_id;
const role = socket.handshake.query.role || 'viewer';
io.use((socket, next) => {
const claims = verifySocketToken(socket.handshake.auth && socket.handshake.auth.token);
if (!claims) return next(new Error('unauthorized'));
socket.data.claims = claims;
next();
});
if (!company_id) {
socket.disconnect();
return;
}
io.on('connection', (socket) => {
const { c: company_id, u: user_id, r: role } = socket.data.claims;
socket.join(`company_${company_id}`);