Security hardening: low-risk easy wins

- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)

Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
This commit is contained in:
Daniel 2026-04-14 02:42:32 +02:00
parent 2f2948339c
commit cf0af53dd4
8 changed files with 239 additions and 35 deletions

115
docs/mobile-build.md Normal file
View file

@ -0,0 +1,115 @@
# Mobile App Build & Release
Capacitor wrappers for **PedScribe** (this repo) and **PedsHub Quiz**
(`/home/danvics/docker/quiz`). Both ship as Android APKs and iOS builds.
## One-time setup
- **Keystore** (reused for both apps):
```bash
keytool -genkeypair -v -keystore ~/pedscribe-release.jks \
-keyalg RSA -keysize 2048 -validity 10000 -alias pedscribe
```
Store the password somewhere safe — losing it means rotating signing keys.
- **Android Studio path** (required when you want to open the IDE):
```bash
export CAPACITOR_ANDROID_STUDIO_PATH="/snap/android-studio/209/bin/studio.sh"
```
Put it in your `~/.bashrc` if you want it permanent.
## Release build — PedScribe
```bash
cd /home/danvics/docker/ped-ai/mobile
npm install # picks up any new plugins
npx cap sync android # copies web assets + plugin glue
cd android
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=$HOME/pedscribe-release.jks \
-Pandroid.injected.signing.store.password=YOUR_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=pedscribe \
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
# APK lands at: android/app/build/outputs/apk/release/app-release.apk
```
## Release build — PedsHub Quiz
```bash
cd /home/danvics/docker/quiz/mobile
npm install
npx cap sync android
cd android
./gradlew assembleRelease \
-Pandroid.injected.signing.store.file=$HOME/pedscribe-release.jks \
-Pandroid.injected.signing.store.password=YOUR_KEYSTORE_PASSWORD \
-Pandroid.injected.signing.key.alias=pedscribe \
-Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD
```
## Publish APK on GitHub Releases
The login page links to `github.com/<owner>/<repo>/releases/latest` (see
`public/index.html` around line 114). Publishing a tagged release updates the
download link automatically — no site redeploy needed.
```bash
cd /home/danvics/docker/ped-ai
gh release create v6.0.1 \
mobile/android/app/build/outputs/apk/release/app-release.apk \
--title "PedScribe 6.0.1" \
--notes "Hardware-backed secure storage for auth token on mobile."
```
For the quiz app:
```bash
cd /home/danvics/docker/quiz
gh release create v1.0.0 \
mobile/android/app/build/outputs/apk/release/app-release.apk \
--title "PedsHub 1.0.0" \
--notes "Initial Android release."
```
## Push source changes to git
Standard flow — the mobile project lives alongside the web app:
```bash
cd /home/danvics/docker/ped-ai
git add mobile/ public/ src/
git commit -m "Your message"
git push
```
Same for quiz at `/home/danvics/docker/quiz`.
## iOS
iOS requires macOS + Xcode. On Linux the sync still runs but you cannot build
the `.ipa`:
```bash
cd /home/danvics/docker/ped-ai/mobile
npx cap sync ios
# Then on a Mac: open ios/App/App.xcworkspace and Archive.
```
## Reinstall on device after rebuild
```bash
adb install -r android/app/build/outputs/apk/release/app-release.apk
```
`-r` preserves app data (saved server URL, cached sessions).
## Troubleshooting
- **`npx cap` can't find the project** — you must `cd mobile/` first, not run
from the repo root.
- **`Keystore was tampered with`** — wrong password. Do not generate a new
keystore unless you are ready to rotate the signing identity on Play Store.
- **Microphone "denied" in the app** — open system settings, long-press the
app icon → App info → Permissions → Microphone → Allow. Web-side prompt
does not always surface because the native layer intercepts it.
- **Foreground recording stops on newer Android** — the service must declare
`foregroundServiceType="microphone"` in `AndroidManifest.xml`.

View file

@ -516,7 +516,10 @@ function showToast(message, type) {
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
toast.innerHTML = '<i class="fas fa-' + icon + '"></i> ' + message;
var iconEl = document.createElement('i');
iconEl.className = 'fas fa-' + icon;
toast.appendChild(iconEl);
toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message)));
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3500);
}

View file

@ -216,6 +216,15 @@ document.addEventListener('DOMContentLoaded', function() {
localStorage.removeItem(SESSION_KEY);
}
window.AUTH_TOKEN = null;
// Clear service worker caches so a logged-out user on a shared device
// cannot read cached pages from offline mode.
try {
if (window.caches && caches.keys) {
caches.keys().then(function(names) {
names.forEach(function(n) { caches.delete(n); });
}).catch(function(){});
}
} catch(e) {}
}
window.getAuthHeaders = function() {

View file

@ -19,6 +19,7 @@ app.set('trust proxy', 1);
// ============================================================
app.use(helmet({
crossOriginEmbedderPolicy: false,
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
@ -53,12 +54,17 @@ app.use(helmet({
var allowedOrigins = [];
if (process.env.APP_URL) allowedOrigins.push(process.env.APP_URL.replace(/\/$/, ''));
if (process.env.CORS_ORIGINS) process.env.CORS_ORIGINS.split(',').forEach(function(o) { allowedOrigins.push(o.trim().replace(/\/$/, '')); });
var IS_PROD = process.env.NODE_ENV === 'production' || !!process.env.APP_URL;
if (IS_PROD && allowedOrigins.length === 0) {
console.error('[FATAL] Production mode but no APP_URL or CORS_ORIGINS set. Refusing to run with open CORS.');
process.exit(1);
}
app.use(cors({
origin: function(origin, callback) {
// Allow requests with no origin (mobile apps, curl, server-to-server)
if (!origin) return callback(null, true);
// In development (no origins configured) allow all
if (allowedOrigins.length === 0) return callback(null, true);
// Development only: if no origins configured, allow all
if (!IS_PROD && allowedOrigins.length === 0) return callback(null, true);
if (allowedOrigins.indexOf(origin) !== -1) return callback(null, true);
callback(new Error('CORS: origin not allowed'));
},
@ -105,6 +111,17 @@ app.use('/api/auth/resend-verification', rateLimit({
message: { error: 'Too many verification requests. Try again in 15 minutes.' },
standardHeaders: true, legacyHeaders: false
}));
// Post-login sensitive endpoints — protect against brute force with a stolen cookie.
// Keyed per-IP; 20 attempts / 15 min covers legitimate use, blocks TOTP brute force (10^6 space).
var sensitiveAuthLimiter = rateLimit({
windowMs: 15 * 60 * 1000, max: 20,
message: { error: 'Too many attempts. Try again in 15 minutes.' },
standardHeaders: true, legacyHeaders: false
});
app.use('/api/auth/change-password', sensitiveAuthLimiter);
app.use('/api/auth/setup-2fa', sensitiveAuthLimiter);
app.use('/api/auth/verify-2fa', sensitiveAuthLimiter);
app.use('/api/auth/disable-2fa', sensitiveAuthLimiter);
// Serve .well-known/assetlinks.json for TWA verification (must be before static)
app.get('/.well-known/assetlinks.json', (req, res) => {
@ -158,7 +175,13 @@ app.get('/api/models', async (req, res) => {
});
const { activeProvider } = require('./src/utils/ai');
// Public health: minimal — just confirm the server is up.
app.get('/api/health', (req, res) => {
res.json({ ok: true });
});
// Detailed health: admin-only — exposes which providers are configured.
const { authMiddleware: _hcAuth, adminMiddleware: _hcAdmin } = require('./src/middleware/auth');
app.get('/api/health/detailed', _hcAuth, _hcAdmin, (req, res) => {
res.json({
status: 'running', version: '6.0.0', provider: activeProvider,
timestamp: new Date().toISOString(),

View file

@ -2,6 +2,13 @@ const jwt = require('jsonwebtoken');
const db = require('../db/database');
const { hashToken } = require('../utils/sessions');
if (!process.env.JWT_SECRET) {
if (process.env.NODE_ENV === 'production' || process.env.APP_URL) {
console.error('[FATAL] JWT_SECRET is not set. Refusing to start in production.');
process.exit(1);
}
console.warn('[WARN] JWT_SECRET not set — using development fallback. DO NOT use in production.');
}
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
async function authMiddleware(req, res, next) {

View file

@ -300,17 +300,23 @@ router.post('/login', async (req, res) => {
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']);
return res.status(403).json({ error: 'Account disabled. Contact an administrator.' });
// Enumeration-resistant: always run bcrypt to keep timing constant, and return
// the same generic message for unknown-user, wrong-password, disabled, unverified.
var DUMMY_HASH = '$2b$12$CwTycUXWue0Thq9StjUM0uJ8.aDLA18dY6nB5xuWz5M6l8lR6rYS.';
if (!user) {
await bcrypt.compare(password, DUMMY_HASH).catch(function(){});
return res.status(401).json({ error: 'Invalid credentials' });
}
var valid = await bcrypt.compare(password, user.password);
if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]);
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
return res.status(401).json({ error: 'Invalid credentials' });
}
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
return res.status(401).json({ error: 'Invalid credentials' });
}

View file

@ -285,6 +285,10 @@ router.post('/suggest-codes', authMiddleware, async function(req, res) {
try {
var { noteText, noteType, patientAge, visitType } = req.body;
if (!noteText) return res.status(400).json({ error: 'No note text provided' });
// ReDoS guard — regex over note text can blow up on pathological inputs.
if (typeof noteText !== 'string' || noteText.length > 20000) {
return res.status(400).json({ error: 'Note too long for billing analysis (max 20,000 chars)' });
}
// 1. Extract diagnoses from note text
var diagnoses = extractDiagnoses(noteText);

View file

@ -7,20 +7,62 @@ var express = require('express');
var router = express.Router();
var crypto = require('crypto');
var jwt = require('jsonwebtoken');
var dns = require('dns').promises;
var net = require('net');
var db = require('../db/database');
var { JWT_SECRET, authMiddleware, adminMiddleware } = require('../middleware/auth');
var { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
// In-memory OIDC state store (short-lived, no DB needed)
var pendingStates = {};
// SSRF guard: reject issuer URLs that resolve to private / loopback / link-local IPs.
// Prevents an admin (or compromised admin account) from pointing OIDC at AWS metadata etc.
function isPrivateIp(ip) {
if (!ip) return true;
if (net.isIPv4(ip)) {
var p = ip.split('.').map(Number);
if (p[0] === 10) return true;
if (p[0] === 127) return true;
if (p[0] === 0) return true;
if (p[0] === 169 && p[1] === 254) return true;
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
if (p[0] === 192 && p[1] === 168) return true;
if (p[0] >= 224) return true;
return false;
}
if (net.isIPv6(ip)) {
var low = ip.toLowerCase();
if (low === '::1' || low === '::' ) return true;
if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true;
return false;
}
return true;
}
async function assertSafeIssuer(urlStr) {
var u = new URL(urlStr);
if (u.protocol !== 'https:') throw new Error('OIDC issuer must use https://');
var addrs = await dns.lookup(u.hostname, { all: true });
for (var i = 0; i < addrs.length; i++) {
if (isPrivateIp(addrs[i].address)) throw new Error('OIDC issuer resolves to a private IP (blocked)');
}
}
// Clean expired states every 10 minutes
setInterval(function() {
var now = Date.now();
Object.keys(pendingStates).forEach(function(k) {
if (pendingStates[k].expires < now) delete pendingStates[k];
});
}, 10 * 60 * 1000);
// Signed, stateless OIDC state — survives restart / scales to multiple processes.
function signState(payload) {
var body = Buffer.from(JSON.stringify(payload)).toString('base64url');
var sig = crypto.createHmac('sha256', JWT_SECRET).update(body).digest('base64url');
return body + '.' + sig;
}
function verifyState(token) {
if (!token || typeof token !== 'string') return null;
var parts = token.split('.');
if (parts.length !== 2) return null;
var expected = crypto.createHmac('sha256', JWT_SECRET).update(parts[0]).digest('base64url');
if (!crypto.timingSafeEqual(Buffer.from(parts[1]), Buffer.from(expected))) return null;
try {
var payload = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8'));
if (!payload.expires || payload.expires < Date.now()) return null;
return payload;
} catch (e) { return null; }
}
function setAuthCookie(res, token) {
var isProduction = process.env.NODE_ENV === 'production' || process.env.APP_URL;
@ -69,19 +111,19 @@ router.get('/oidc', async function(req, res) {
var appUrl = (process.env.APP_URL || 'http://localhost:3000').replace(/\/$/, '');
var redirectUri = appUrl + '/api/auth/oidc/callback';
await assertSafeIssuer(issuer);
var config = await oidc.discovery(new URL(issuer), clientId);
var state = crypto.randomBytes(24).toString('hex');
var nonce = crypto.randomBytes(24).toString('hex');
var codeVerifier = oidc.randomPKCECodeVerifier();
var codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
// Store state for callback verification (5 min TTL)
pendingStates[state] = {
nonce: nonce,
codeVerifier: codeVerifier,
// Stateless signed state — survives restart, works across multiple processes.
var state = signState({
n: nonce,
v: codeVerifier,
expires: Date.now() + 5 * 60 * 1000
};
});
var authUrl = oidc.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
@ -105,17 +147,11 @@ router.get('/oidc/callback', async function(req, res) {
try {
var state = req.query.state;
if (!state || !pendingStates[state]) {
var pending = verifyState(state);
if (!pending) {
return res.redirect(appUrl + '?error=invalid_state');
}
var pending = pendingStates[state];
delete pendingStates[state];
if (pending.expires < Date.now()) {
return res.redirect(appUrl + '?error=expired');
}
var issuer = await db.getSetting('oidc.issuer');
var clientId = await db.getSetting('oidc.client_id');
var clientSecret = await db.getSetting('oidc.client_secret');
@ -123,11 +159,12 @@ router.get('/oidc/callback', async function(req, res) {
var oidc = require('openid-client');
var redirectUri = appUrl + '/api/auth/oidc/callback';
await assertSafeIssuer(issuer);
var config = await oidc.discovery(new URL(issuer), clientId, clientSecret || undefined);
var tokens = await oidc.authorizationCodeGrant(config, new URL(req.protocol + '://' + req.get('host') + req.originalUrl), {
pkceCodeVerifier: pending.codeVerifier,
expectedNonce: pending.nonce,
pkceCodeVerifier: pending.v,
expectedNonce: pending.n,
expectedState: state
});