security(encryptid): gate /api/internal/* against public reach

The /api/internal/* routes (user lookups, space invites, fund claims, agent
mailbox creds) were publicly reachable via auth.rspace.online because Traefik
forwards everything under the encryptid host rule. They were designed for
service-to-service calls over the Docker network only.

Add a Hono middleware that rejects /api/internal/* with 404 when the request
arrives through the public edge (X-Forwarded-For / X-Real-IP set by Traefik)
unless it carries a valid X-Internal-Key matching INTERNAL_API_KEY. Direct
container-to-container fetches don't set forwarded headers so existing
callers keep working untouched.
This commit is contained in:
Jeff Emmett 2026-04-17 10:05:49 -04:00
parent cfe060dc61
commit 0a896f5740
2 changed files with 17 additions and 1 deletions

View File

@ -245,6 +245,7 @@ services:
- MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080}
- MAILCOW_API_KEY=${MAILCOW_API_KEY:-}
- ADMIN_DIDS=${ADMIN_DIDS}
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
labels:
- "traefik.enable=true"
- "traefik.http.routers.encryptid.rule=Host(`auth.rspace.online`) || Host(`auth.ridentity.online`) || Host(`encryptid.jeffemmett.com`)"

View File

@ -480,10 +480,25 @@ app.use('*', cors({
return undefined;
},
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Internal-Key'],
credentials: true,
}));
// /api/internal/* is for service-to-service calls over the Docker network only.
// Traefik adds X-Forwarded-For for any request arriving through the public edge
// (CF tunnel, direct HTTPS). Direct container-to-container fetches do not set it.
// A shared INTERNAL_API_KEY also works for callers outside the Docker net.
app.use('/api/internal/*', async (c, next) => {
const forwarded = c.req.header('X-Forwarded-For') || c.req.header('X-Real-IP');
const providedKey = c.req.header('X-Internal-Key');
const expectedKey = process.env.INTERNAL_API_KEY;
const hasValidKey = !!(expectedKey && providedKey && providedKey === expectedKey);
if (forwarded && !hasValidKey) {
return c.json({ error: 'Not found' }, 404);
}
return next();
});
// ============================================================================
// STATIC FILES & WELL-KNOWN
// ============================================================================