Skip to content

Commit 43539d9

Browse files
[mirotalk-admin] - security: enforce HTTPS-only on the admin dashboard, kill plaintext fall-through
1 parent a9c3570 commit 43539d9

8 files changed

Lines changed: 203 additions & 19 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ backend/config/index.js
66
docker-compose.yml
77
node_modules
88
npm-debug.log
9-
security
9+
security
10+
backend/ssl/dev

README.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,119 @@ MIROTALK_ADMIN_DIR=/root/mirotalk-admin
162162
> Regularly review and update your `.env` settings to keep your MiroTalk instance secure and manageable.
163163

164164
---
165+
166+
### 🔒 TLS / HTTPS in Production
167+
168+
The dashboard listens on a single HTTPS port (`ADMIN_PORT`) via the
169+
Node.js stdlib `https` module, plaintext HTTP requests on this port are
170+
rejected at the TLS layer and cannot reach the application.
171+
172+
- Defense-in-depth: when `NODE_ENV=production`, any request whose
173+
`req.secure` is false (e.g. arriving through a misconfigured reverse
174+
proxy that did not set `X-Forwarded-Proto: https`) is 301-redirected
175+
to HTTPS for `GET`/`HEAD` and rejected with `403` for any other
176+
method. Plaintext `ws://` socket upgrades are likewise refused.
177+
- The bundled certificate in [backend/ssl/](backend/ssl/) is **self-signed**
178+
and intended for first-boot / local development only. For production,
179+
either:
180+
- replace `backend/ssl/key.pem` and `backend/ssl/cert.pem` with a
181+
certificate issued by a trusted CA (e.g. Let's Encrypt), or
182+
- put the dashboard behind a TLS-terminating reverse proxy
183+
(nginx, Caddy, Traefik, an ALB, etc.) and bind the dashboard to
184+
`127.0.0.1`. When doing so, set `TRUST_PROXY=true` so the
185+
`X-Forwarded-Proto` and `X-Forwarded-For` headers are honored.
186+
- Responses include `Strict-Transport-Security: max-age=63072000; includeSubDomains`
187+
to pin compliant browsers to HTTPS.
188+
189+
#### Local development with a trusted cert
190+
191+
The bundled `backend/ssl/cert.pem` is self-signed, so on the first visit
192+
your browser will show a "Not secure / self-signed certificate" warning.
193+
Click through it once (Chrome: type `thisisunsafe` on the warning page,
194+
or **Advanced → Proceed**; Firefox: **Accept the Risk and Continue**) and
195+
the dashboard at <https://localhost:9999/admin> will load.
196+
197+
To remove the warning entirely in development, install
198+
[`mkcert`](https://github.com/FiloSottile/mkcert) and issue a cert that
199+
your OS / browsers actually trust. Place the dev cert in
200+
`backend/ssl/dev/`, the server auto-detects it when `NODE_ENV` is not
201+
`production` and falls back to `backend/ssl/` otherwise:
202+
203+
<details>
204+
<summary>🍎 macOS</summary>
205+
206+
<br/>
207+
208+
```bash
209+
brew install mkcert nss
210+
mkcert -install
211+
212+
mkdir -p backend/ssl/dev
213+
cd backend/ssl/dev
214+
mkcert -key-file key.pem -cert-file cert.pem localhost 127.0.0.1 ::1
215+
```
216+
217+
</details>
218+
219+
<details>
220+
<summary>🐧 Linux (Debian / Ubuntu)</summary>
221+
222+
<br/>
223+
224+
```bash
225+
sudo apt update
226+
sudo apt install -y libnss3-tools
227+
# install the mkcert binary (pick the latest release for your arch)
228+
curl -fsSLo /tmp/mkcert "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
229+
sudo install -m 0755 /tmp/mkcert /usr/local/bin/mkcert
230+
mkcert -install
231+
232+
mkdir -p backend/ssl/dev
233+
cd backend/ssl/dev
234+
mkcert -key-file key.pem -cert-file cert.pem localhost 127.0.0.1 ::1
235+
```
236+
237+
> On Fedora/RHEL use `sudo dnf install nss-tools`; on Arch use
238+
> `sudo pacman -S nss mkcert`. For Firefox to trust the cert, make sure
239+
> `libnss3-tools` (or `nss-tools`) is installed **before** running
240+
> `mkcert -install`.
241+
242+
</details>
243+
244+
<details>
245+
<summary>🪟 Windows (PowerShell, run as Administrator)</summary>
246+
247+
<br/>
248+
249+
```powershell
250+
# Easiest path: Chocolatey
251+
choco install mkcert
252+
# …or Scoop:
253+
# scoop bucket add extras; scoop install mkcert
254+
mkcert -install
255+
256+
mkdir backend\ssl\dev
257+
cd backend\ssl\dev
258+
mkcert -key-file key.pem -cert-file cert.pem localhost 127.0.0.1 ::1
259+
```
260+
261+
> Firefox on Windows uses its own trust store. After `mkcert -install`,
262+
> restart Firefox so it picks up the new local CA. Chrome / Edge use the
263+
> Windows trust store and work immediately.
264+
265+
</details>
266+
267+
<br/>
268+
269+
`backend/ssl/dev/` is gitignored, so the locally-trusted cert never
270+
ships with the repo. In production the server always uses
271+
`backend/ssl/key.pem` + `backend/ssl/cert.pem`, which should hold a
272+
CA-issued cert (or be replaced by a TLS-terminating reverse proxy as
273+
described above).
274+
275+
Restart the server and the lock icon will be green. The dev workflow is
276+
otherwise unchanged, the dashboard is HTTPS-only in every environment
277+
so behavior in dev matches production (`Secure` cookies, HSTS, mixed
278+
content, `wss://` upgrades, etc.).
279+
280+
---

backend/app.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ app.set('trust proxy', TRUST_PROXY === true);
2727
app.use(cors(utils.getCorsOptions()));
2828

2929
app.use(helmet.noSniff());
30+
// HTTP Strict Transport Security: instruct compliant browsers to only ever
31+
// reach this origin over HTTPS for the next 2 years, mitigating the
32+
// plaintext-HTTP fall-through that httpolyglot would otherwise allow on
33+
// the same port.
34+
app.use(
35+
helmet.hsts({
36+
maxAge: 63072000,
37+
includeSubDomains: true,
38+
preload: false,
39+
})
40+
);
3041
app.use(express.urlencoded({ extended: true }));
3142
app.use(express.json());
3243

backend/handlers/index.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ const { Logs, getIP } = require('../utils');
1515
const config = require('../config');
1616
const logger = new Logs('AdminSocket');
1717

18-
const { ADMIN_DASHBOARD_ENABLED, ADMIN_ALLOWED_IPS } = config;
18+
const { ADMIN_DASHBOARD_ENABLED, ADMIN_ALLOWED_IPS, NODE_ENV } = config;
19+
20+
const IS_PRODUCTION = NODE_ENV === 'production';
1921

2022
/**
2123
* Socket.IO transport-level gate.
@@ -34,6 +36,19 @@ function socketAccessGate(socket, next) {
3436
logger.warn('Socket connection refused: dashboard disabled');
3537
return next(new Error('dashboard disabled'));
3638
}
39+
// Reject plaintext ws:// upgrades in production. httpolyglot accepts
40+
// both HTTP and HTTPS on the same port, so without this check an
41+
// attacker on the wire could open an unauthenticated WebSocket in the
42+
// clear and intercept the JWT used to upgrade it.
43+
if (IS_PRODUCTION) {
44+
const encrypted = Boolean(socket.request && socket.request.socket && socket.request.socket.encrypted);
45+
const xfProto = socket.request && socket.request.headers && socket.request.headers['x-forwarded-proto'];
46+
const viaTlsProxy = typeof xfProto === 'string' && xfProto.split(',')[0].trim().toLowerCase() === 'https';
47+
if (!encrypted && !viaTlsProxy) {
48+
logger.warn('Socket connection refused: plaintext transport');
49+
return next(new Error('https required'));
50+
}
51+
}
3752
const allowed = Array.isArray(ADMIN_ALLOWED_IPS) ? ADMIN_ALLOWED_IPS : [];
3853
if (allowed.length > 0 && !allowed.includes('*')) {
3954
const ip = getIP(socket.request);

backend/middleware/dashboardGuard.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,24 @@ const config = require('../config');
1414
const utils = require('../utils');
1515
const Logs = utils.Logs;
1616
const logger = new Logs('DashboardGuardMiddleware');
17-
const { ADMIN_DASHBOARD_ENABLED } = config;
17+
const { ADMIN_DASHBOARD_ENABLED, NODE_ENV } = config;
18+
19+
const IS_PRODUCTION = NODE_ENV === 'production';
1820

1921
/**
20-
* Middleware to check if the dashboard is enabled.
22+
* Middleware to check if the dashboard is enabled and, in production, to
23+
* enforce HTTPS. Because the server is started with httpolyglot (the same
24+
* port accepts both plaintext HTTP and TLS), an on-path attacker could
25+
* otherwise intercept the admin login and steal the JWT in cleartext.
26+
*
27+
* In production, any plaintext-HTTP request is rejected. GET/HEAD requests
28+
* are 301-redirected to the equivalent https:// URL so an operator who
29+
* typed http:// (or followed a bad link) still lands on the secure
30+
* endpoint; all other methods are refused with 403 to avoid silently
31+
* "fixing up" requests whose body has already been sent in cleartext.
32+
*
33+
* In non-production environments the check is a warning-only no-op so
34+
* local development against the self-signed cert is not disrupted.
2135
*
2236
* @param {express.Request} req - Express request object
2337
* @param {express.Response} res - Express response object
@@ -28,6 +42,22 @@ const dashboardEnabledAndHttps = (req, res, next) => {
2842
logger.warn('Admin dashboard is disabled', { url: req.originalUrl });
2943
return res.status(503).json({ error: 'Admin dashboard is disabled' });
3044
}
45+
// `req.secure` is true when the request arrived over TLS directly, or
46+
// (when `trust proxy` is set) when a trusted upstream proxy set
47+
// X-Forwarded-Proto=https. Both cases are acceptable.
48+
if (!req.secure) {
49+
if (IS_PRODUCTION) {
50+
const host = req.headers.host;
51+
const safeUrl = { url: req.originalUrl, ip: req.ip };
52+
if ((req.method === 'GET' || req.method === 'HEAD') && host) {
53+
logger.warn('Redirecting plaintext HTTP request to HTTPS', safeUrl);
54+
return res.redirect(301, `https://${host}${req.originalUrl}`);
55+
}
56+
logger.warn('Rejecting plaintext HTTP request', { ...safeUrl, method: req.method });
57+
return res.status(403).json({ error: 'HTTPS required' });
58+
}
59+
logger.warn('Plaintext HTTP request accepted (non-production)', { url: req.originalUrl });
60+
}
3161
next();
3262
};
3363

backend/server.js

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
const path = require('path');
1313
const fs = require('fs');
14+
const https = require('https');
1415
const app = require('./app');
1516
const config = require('./config');
1617
const utils = require('./utils');
@@ -21,18 +22,38 @@ const logger = new Logs('AdminServer');
2122

2223
const { ADMIN_PORT } = config;
2324

24-
// Define paths to the SSL key and certificate files
25-
const keyPath = path.join(__dirname, 'ssl/key.pem');
26-
const certPath = path.join(__dirname, 'ssl/cert.pem');
25+
// Define paths to the SSL key and certificate files.
26+
// In non-production environments, prefer `backend/ssl/dev/` if present
27+
// (e.g. a locally-trusted cert issued via `mkcert`), so the browser does
28+
// not show a self-signed-cert warning during day-to-day development.
29+
// In production, always use the canonical `backend/ssl/` pair, which
30+
// should hold a CA-issued certificate (Let's Encrypt, internal PKI, …)
31+
// or be fronted by a TLS-terminating reverse proxy.
32+
const IS_PRODUCTION = config.NODE_ENV === 'production';
33+
const devKeyPath = path.join(__dirname, 'ssl/dev/key.pem');
34+
const devCertPath = path.join(__dirname, 'ssl/dev/cert.pem');
35+
const prodKeyPath = path.join(__dirname, 'ssl/key.pem');
36+
const prodCertPath = path.join(__dirname, 'ssl/cert.pem');
37+
38+
const useDevCerts = !IS_PRODUCTION && fs.existsSync(devKeyPath) && fs.existsSync(devCertPath);
39+
const keyPath = useDevCerts ? devKeyPath : prodKeyPath;
40+
const certPath = useDevCerts ? devCertPath : prodCertPath;
41+
42+
if (useDevCerts) {
43+
logger.info('Using development SSL certificates from backend/ssl/dev/');
44+
}
2745

2846
// Read SSL key and certificate files securely
2947
const options = {
3048
key: fs.readFileSync(keyPath, 'utf-8'),
3149
cert: fs.readFileSync(certPath, 'utf-8'),
3250
};
3351

34-
// Serve both http and https and attach Socket.IO
35-
const server = require('httpolyglot').createServer(options, app);
52+
// HTTPS-only listener. Previously this used `httpolyglot`, which accepts
53+
// both HTTP and HTTPS on the same port and made the dashboard reachable
54+
// in cleartext (login + JWT). Using `https.createServer` makes plaintext
55+
// access structurally impossible on this port.
56+
const server = https.createServer(options, app);
3657
const { Server } = require('socket.io');
3758
const io = new Server(server, {
3859
cors: utils.getCorsOptions(),

package-lock.json

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
"express": "^5.2.1",
3030
"express-rate-limit": "^8.5.2",
3131
"helmet": "^8.1.0",
32-
"httpolyglot": "0.1.2",
3332
"jsonwebtoken": "^9.0.3",
3433
"node-pty": "^1.1.0",
3534
"nodemailer": "^8.0.7",

0 commit comments

Comments
 (0)