fix: metrics are not public, and the workspace launcher renders again
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m51s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

/metrics answered on every public hostname — app.pedshub.com, peds.danvics.com
and scribe.pedshub.com — with 201 lines naming routes, traffic volumes,
event-loop timings and process internals. No credential in it, but a free map of
the application for anyone probing. Prometheus scrapes the container directly
over the Docker network and never goes through the proxy, so a request carrying
X-Forwarded-For is by definition not Prometheus and now gets a 404.
METRICS_TOKEN allows an explicit override; unset, it can never match.
Verified: 404 on all three hostnames, and up{job="ped-ai"} still 1.

The workspace launcher was blank. [hidden]{display:none!important}, added to
stop "Retry loading settings" showing permanently, outranked
`body.assistant-mode-workspace .assistant-workspace-view { display:block }` —
and that view is the one element toggled by CSS rather than by JavaScript
removing the attribute, so it could never be revealed again. The attribute is
gone from the markup; the class already defaults it to display:none, so it still
starts hidden. Everything else using the attribute is JS-toggled and unaffected.
Verified with a real login: 17 cards at 208x40 in an 860x240 grid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 05:18:02 +02:00
parent fadf09bf4a
commit 5394fc930b
4 changed files with 45 additions and 2 deletions

View file

@ -279,3 +279,10 @@ DB_PASSWORD=pedscribe_secret_change_me
# OPENBAO_ROLE_ID=
# OPENBAO_SECRET_ID=
# OPENBAO_KV_PATH=kv/ped-ai/prod
# Optional. Lets something outside this host scrape /metrics with
# `Authorization: Bearer <token>`. Unset by default, and an unset token can
# never match — Prometheus scrapes pediatric-ai-scribe:3000 directly over the
# Docker network, which needs no token. Requests arriving through the reverse
# proxy (they carry X-Forwarded-For) get a 404 either way.
METRICS_TOKEN=

View file

@ -54,7 +54,11 @@
<!-- Workspace launcher, rendered from the app's real .tab-btn list so
it cannot drift from the menu it mirrors. Desktop only: on a phone
the drawer already is the menu. -->
<div id="assistant-workspace-view" class="assistant-workspace-view" hidden>
<!-- No hidden attribute: this view is revealed by CSS on
body.assistant-mode-workspace, and [hidden]{display:none!important}
outranks that. .assistant-workspace-view is display:none by
default, so it starts hidden either way. -->
<div id="assistant-workspace-view" class="assistant-workspace-view">
<div id="assistant-workspace-cards" class="assistant-workspace-cards"></div>
</div>
<div id="assistant-chat-view">

View file

@ -186,7 +186,22 @@ app.get('/api/build', function(req, res) { res.json({ buildId: BUILD_ID }); });
// only after the first answer, so a fresh Grafana panel reads "no data"
// instead of zero — which looks like broken wiring rather than a quiet day.
require('./src/utils/citationAudit');
app.get('/metrics', metricsHandler);
// Metrics are for Prometheus, which scrapes pediatric-ai-scribe:3000 directly
// over the Docker network. Served unguarded, this endpoint answered on every
// public hostname: 201 lines naming routes, traffic volumes, event-loop timings
// and process internals to anyone who asked. Nothing here is a credential, but
// it is a free map of the application for someone probing it.
//
// A request that arrived through the reverse proxy carries X-Forwarded-For;
// one from a container on our own network does not. That is the distinction
// that matters here, and it does not depend on getting a CIDR list right.
// METRICS_TOKEN allows an explicit override for scraping from elsewhere.
app.get('/metrics', function (req, res, next) {
var token = process.env.METRICS_TOKEN;
if (token && req.get('authorization') === 'Bearer ' + token) return next();
if (req.get('x-forwarded-for')) return res.status(404).end();
return next();
}, metricsHandler);
app.use(loggingMiddleware);
app.use('/vendor/dompurify', express.static(path.join(__dirname, 'node_modules', 'dompurify', 'dist'), {

View file

@ -576,3 +576,20 @@ test('a router mounted on /api must not gate the whole namespace', () => {
}
});
test('metrics are not served to the internet', () => {
// Prometheus scrapes pediatric-ai-scribe:3000 directly over the Docker
// network. Served unguarded this answered on every public hostname with 201
// lines naming routes, traffic volumes and process internals — a free map of
// the application for anyone probing it. A request through the reverse proxy
// carries X-Forwarded-For; one from our own network does not.
const server = readSource('server.js');
const handler = server.slice(server.indexOf("app.get('/metrics'"), server.indexOf('metricsHandler);') + 16);
assert.match(handler, /if \(req\.get\('x-forwarded-for'\)\) return res\.status\(404\)\.end\(\);/,
'a proxied request gets nothing');
assert.match(handler, /process\.env\.METRICS_TOKEN/, 'with an explicit override for scraping from elsewhere');
assert.match(handler, /req\.get\('authorization'\) === 'Bearer ' \+ token/);
// The override must not be a way in when it is unset.
assert.match(handler, /var token = process\.env\.METRICS_TOKEN;\s*\n\s*if \(token &&/,
'an unset token can never match');
});