diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf72acb..726e70b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,7 +18,10 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 + + strategy: + fail-fast: false steps: - name: Checkout repository @@ -29,12 +32,10 @@ jobs: with: languages: javascript-typescript build-mode: none + queries: +security-extended - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 - # Analysis runs and detects vulnerabilities regardless of upload. - # Upload may fail on private repos without GitHub Advanced Security - # enabled — this does not indicate a code quality problem. - continue-on-error: true with: category: "/language:javascript-typescript" + upload: always diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e57a3aa..c90ade1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -160,3 +160,28 @@ jobs: description: `Dependency audit ${state}`, target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` }); + + report-snyk-status: + name: Report snyk status + runs-on: ubuntu-latest + needs: snyk + if: always() + steps: + - name: Set snyk commit status + uses: actions/github-script@v9 + with: + script: | + const result = '${{ needs.snyk.result }}'; + const state = (result === 'success' || result === 'skipped') ? 'success' : 'failure'; + const sha = context.payload.pull_request + ? context.payload.pull_request.head.sha + : context.sha; + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state, + context: 'snyk', + description: `Snyk vulnerability scan ${state}`, + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` + }); diff --git a/electron/database/init.cjs b/electron/database/init.cjs index b1d5d53..bea9b3e 100644 --- a/electron/database/init.cjs +++ b/electron/database/init.cjs @@ -590,7 +590,7 @@ async function initDatabase() { if (process.env.NODE_ENV === 'development') { console.log('Encrypted database initialized successfully'); console.log(`Encryption enabled: ${encryptionEnabled}`); - console.log(`Default organization: ${defaultOrg.id}`); + console.log('Default organization: [configured]'); } return db; diff --git a/electron/services/mfa.cjs b/electron/services/mfa.cjs index bbf0dd0..1d967d9 100644 --- a/electron/services/mfa.cjs +++ b/electron/services/mfa.cjs @@ -147,13 +147,16 @@ function decryptSecret(stored) { // ---------------- Backup codes ---------------- function generateBackupCodes(count = BACKUP_CODE_COUNT) { - const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // exclude ambiguous chars + const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const limit = 256 - (256 % charset.length); const codes = []; for (let i = 0; i < count; i++) { - const buf = crypto.randomBytes(BACKUP_CODE_LENGTH); let s = ''; - for (let j = 0; j < BACKUP_CODE_LENGTH; j++) { - s += charset[buf[j] % charset.length]; + while (s.length < BACKUP_CODE_LENGTH) { + const buf = crypto.randomBytes(1); + if (buf[0] < limit) { + s += charset[buf[0] % charset.length]; + } } codes.push(s.slice(0, 5) + '-' + s.slice(5)); } diff --git a/electron/services/siemForwarder.cjs b/electron/services/siemForwarder.cjs index ec77153..bec9d36 100644 --- a/electron/services/siemForwarder.cjs +++ b/electron/services/siemForwarder.cjs @@ -135,7 +135,8 @@ function toRfc5424(record) { const app = 'transtrack'; const procid = process.pid; const msgid = String(record.action || 'audit').slice(0, 32); - const sd = `[transtrack@53914 org="${(record.org_id || '').replace(/"/g, '\\"')}" user="${(record.user_email || '').replace(/"/g, '\\"')}" entity="${(record.entity_type || '')}" id="${(record.entity_id || '')}"]`; + const esc = (s) => String(s || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\]/g, '\\]'); + const sd = `[transtrack@53914 org="${esc(record.org_id)}" user="${esc(record.user_email)}" entity="${esc(record.entity_type)}" id="${esc(record.entity_id)}"]`; const msg = String(record.details || '').replace(/[\r\n]+/g, ' '); return `<${pri}>1 ${ts} ${HOSTNAME} ${app} ${procid} ${msgid} ${sd} ${msg}`; } @@ -198,7 +199,7 @@ function ensureSocket(dest, st) { sock.on('close', () => { st.socket = null; }); st.socket = sock; } else if (dest.protocol === 'tls') { - const sock = tls.connect({ host: dest.host, port: dest.port, rejectUnauthorized: false }); + const sock = tls.connect({ host: dest.host, port: dest.port, rejectUnauthorized: true }); sock.on('error', (err) => { recordFailure(dest.id, err.message); try { sock.destroy(); } catch {} st.socket = null; }); sock.on('close', () => { st.socket = null; }); st.socket = sock; diff --git a/functions/fhirWebhook.ts b/functions/fhirWebhook.ts index caf538a..1990b60 100644 --- a/functions/fhirWebhook.ts +++ b/functions/fhirWebhook.ts @@ -3,6 +3,18 @@ import { createLogger, generateRequestId, safeErrorResponse } from './lib/logger const logger = createLogger('fhirWebhook'); +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + const encoder = new TextEncoder(); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); + let result = 0; + for (let i = 0; i < bufA.length; i++) { + result |= bufA[i] ^ bufB[i]; + } + return result === 0; +} + Deno.serve(async (req) => { const requestId = generateRequestId(); @@ -17,8 +29,8 @@ Deno.serve(async (req) => { }, { status: 503 }); } - // Simple bearer token auth - if (!authHeader || authHeader !== `Bearer ${webhookSecret}`) { + const expectedToken = `Bearer ${webhookSecret}`; + if (!authHeader || !timingSafeEqual(authHeader, expectedToken)) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/functions/pushToEHR.ts b/functions/pushToEHR.ts index 23a37f6..4b9e037 100644 --- a/functions/pushToEHR.ts +++ b/functions/pushToEHR.ts @@ -64,10 +64,26 @@ Deno.serve(async (req) => { authHeaders['Content-Type'] = 'application/fhir+json'; authHeaders['Accept'] = 'application/fhir+json'; - // Push to EHR system + // Validate endpoint URL to prevent SSRF + let endpointUrl: URL; + try { + endpointUrl = new URL(integration.endpoint_url); + } catch { + return Response.json({ error: 'Invalid integration endpoint URL' }, { status: 400 }); + } + if (endpointUrl.protocol !== 'https:' && endpointUrl.protocol !== 'http:') { + return Response.json({ error: 'Unsupported endpoint protocol' }, { status: 400 }); + } + const hostname = endpointUrl.hostname; + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || + hostname.startsWith('10.') || hostname.startsWith('192.168.') || + hostname.startsWith('169.254.') || hostname.endsWith('.internal')) { + return Response.json({ error: 'Endpoint resolves to restricted address' }, { status: 400 }); + } + let ehrResponse; try { - const pushResponse = await fetch(integration.endpoint_url, { + const pushResponse = await fetch(endpointUrl.toString(), { method: 'POST', headers: authHeaders, body: JSON.stringify(fhirBundle) @@ -76,7 +92,6 @@ Deno.serve(async (req) => { ehrResponse = { status: pushResponse.status, statusText: pushResponse.statusText, - body: await pushResponse.text() }; if (!pushResponse.ok) { @@ -128,7 +143,6 @@ Deno.serve(async (req) => { synced_fields: syncedFields, errors, sync_log_id: syncLog.id, - ehr_response: ehrResponse }); } catch (error) { logger.error('EHR push failed', error, { request_id: requestId }); diff --git a/functions/validateFHIRData.ts b/functions/validateFHIRData.ts index 8a2d7b0..f7a6ed9 100644 --- a/functions/validateFHIRData.ts +++ b/functions/validateFHIRData.ts @@ -106,9 +106,14 @@ Deno.serve(async (req) => { case 'regex_pattern': if (fieldValue) { const pattern = rule.validation_config?.pattern; - if (pattern) { - const regex = new RegExp(pattern); - isValid = regex.test(fieldValue); + if (pattern && typeof pattern === 'string' && pattern.length <= 200) { + try { + const regex = new RegExp(pattern); + const testValue = String(fieldValue).slice(0, 1000); + isValid = regex.test(testValue); + } catch { + isValid = false; + } if (!isValid) { errorMsg = rule.error_message || `Field '${rule.target_field}' does not match required pattern`; } diff --git a/scripts/epic-sandbox-test.mjs b/scripts/epic-sandbox-test.mjs index c1239eb..cd0feb1 100644 --- a/scripts/epic-sandbox-test.mjs +++ b/scripts/epic-sandbox-test.mjs @@ -131,7 +131,7 @@ async function fhirGet(token, path) { const patient = await fhirGet(tok.access_token, `Patient/${PATIENT_ID}`); const name = patient.name?.[0]; console.log( - ` ${name?.family}, ${name?.given?.join(' ')} | DOB ${patient.birthDate} | gender ${patient.gender}`, + ` ${name?.family ? name.family[0] + '***' : '?'}, ${name?.given?.[0]?.[0] || '?'}*** | DOB ****-**-${patient.birthDate?.slice(-2) || '**'} | gender ${patient.gender}`, ); console.log(''); @@ -141,14 +141,6 @@ async function fhirGet(token, path) { `Observation?patient=${PATIENT_ID}&category=laboratory&_count=10`, ); console.log(` ${labs.entry?.length || 0} lab observations`); - for (const e of labs.entry?.slice(0, 5) || []) { - const o = e.resource; - const v = - o.valueQuantity?.value !== undefined - ? `${o.valueQuantity.value} ${o.valueQuantity.unit || ''}` - : o.valueCodeableConcept?.text || '(no value)'; - console.log(` - ${o.code?.text || o.code?.coding?.[0]?.display}: ${v}`); - } console.log(''); console.log('Step 4 - GET Condition?patient=...&category=problem-list-item'); @@ -157,12 +149,6 @@ async function fhirGet(token, path) { `Condition?patient=${PATIENT_ID}&category=problem-list-item&_count=10`, ); console.log(` ${probs.entry?.length || 0} active problems`); - for (const e of probs.entry?.slice(0, 5) || []) { - const c = e.resource; - console.log( - ` - ${c.code?.text || c.code?.coding?.[0]?.display} [${c.clinicalStatus?.coding?.[0]?.code}]`, - ); - } console.log(''); console.log('Step 5 - GET MedicationRequest?patient=...'); @@ -171,15 +157,6 @@ async function fhirGet(token, path) { `MedicationRequest?patient=${PATIENT_ID}&_count=10`, ); console.log(` ${meds.entry?.length || 0} medication requests`); - for (const e of meds.entry?.slice(0, 5) || []) { - const m = e.resource; - const drug = - m.medicationCodeableConcept?.text || - m.medicationCodeableConcept?.coding?.[0]?.display || - m.medicationReference?.display || - '(unknown)'; - console.log(` - ${drug} [status=${m.status}]`); - } console.log(''); console.log('Step 6 - GET AllergyIntolerance?patient=...'); @@ -188,16 +165,10 @@ async function fhirGet(token, path) { `AllergyIntolerance?patient=${PATIENT_ID}&_count=10`, ); console.log(` ${alg.entry?.length || 0} allergies`); - for (const e of alg.entry?.slice(0, 5) || []) { - const a = e.resource; - console.log( - ` - ${a.code?.text || a.code?.coding?.[0]?.display} [${a.clinicalStatus?.coding?.[0]?.code || 'unknown'}]`, - ); - } console.log(''); console.log('SUCCESS - Epic sandbox round-trip complete.'); -})().catch((e) => { - console.error('FAILED:', e.message); - process.exit(1); +})().catch(() => { + console.error('FAILED: request failed. Check configuration and endpoint availability.'); + process.exitCode = 1; }); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 90885fc..f6a4317 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -376,8 +376,8 @@ function sendMllp(message) { await pool.end(); console.log('\n\x1b[42m\x1b[30m SMOKE TEST PASSED \x1b[0m\n'); -})().catch(e => { +})().catch(() => { console.error('\n\x1b[41m\x1b[37m SMOKE TEST FAILED \x1b[0m'); - console.error(e); + console.error('Smoke test failed. Check service logs or run with a debugger for details.'); process.exit(1); }); diff --git a/server/src/config.js b/server/src/config.js index 014713b..949e841 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -68,6 +68,7 @@ const schema = z.object({ SIEM_ENDPOINT: z.string().optional().default(''), SIEM_TOKEN: z.string().optional().default(''), + CORS_ALLOWED_ORIGINS: z.string().optional().default(''), SUBSCRIPTION_DISPATCH_MS: z.coerce.number().int().positive().default(5000), SMART_DEFAULT_ACCESS_TTL_SECONDS: z.coerce.number().int().positive().default(3600), diff --git a/server/src/db/pool.js b/server/src/db/pool.js index 992891c..fd940a5 100644 --- a/server/src/db/pool.js +++ b/server/src/db/pool.js @@ -6,7 +6,7 @@ let pool = null; function init(config, logger) { if (pool) return pool; - const ssl = config.PGSSL === 'disable' ? false : { rejectUnauthorized: config.PGSSL === 'verify-full' }; + const ssl = config.PGSSL === 'disable' ? false : { rejectUnauthorized: config.PGSSL !== 'disable' }; pool = new Pool({ connectionString: config.DATABASE_URL, max: config.PG_POOL_MAX, diff --git a/server/src/fhir/subscriptions.js b/server/src/fhir/subscriptions.js index 40470d9..f22ea4f 100644 --- a/server/src/fhir/subscriptions.js +++ b/server/src/fhir/subscriptions.js @@ -17,8 +17,49 @@ const https = require('https'); const http = require('http'); +const dns = require('dns'); const { withTransaction, getPool } = require('../db/pool'); +const FORBIDDEN_HEADER_NAMES = new Set([ + 'host', 'content-length', 'transfer-encoding', 'connection', + 'keep-alive', 'upgrade', 'proxy-authorization', 'te', +]); + +function sanitizeHeaders(raw) { + if (!raw || typeof raw !== 'object') return {}; + const clean = {}; + for (const [k, v] of Object.entries(raw)) { + const lower = k.toLowerCase(); + if (FORBIDDEN_HEADER_NAMES.has(lower)) continue; + const sv = String(v).replace(/[\r\n]/g, ''); + clean[k] = sv; + } + return clean; +} + +const PRIVATE_RANGES = [ + /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, + /^0\./, /^169\.254\./, /^::1$/, /^fc00:/i, /^fe80:/i, /^fd/i, +]; + +function isPrivateIp(ip) { + return PRIVATE_RANGES.some(r => r.test(ip)); +} + +async function resolveAndValidateUrl(endpoint) { + const url = new URL(endpoint); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('unsupported protocol'); + } + const addresses = await dns.promises.resolve4(url.hostname).catch(() => []); + const addresses6 = await dns.promises.resolve6(url.hostname).catch(() => []); + const all = [...addresses, ...addresses6]; + if (all.some(isPrivateIp)) { + throw new Error('subscription endpoint resolves to private IP'); + } + return url; +} + let dispatcherStarted = false; /** @@ -147,16 +188,18 @@ async function deliverOne(row) { const headers = { 'Content-Type': row.payload_mime || 'application/fhir+json', 'Content-Length': Buffer.byteLength(payload), - ...(row.header || {}), + ...sanitizeHeaders(row.header), }; + let url; + try { + url = await resolveAndValidateUrl(row.endpoint); + } catch (e) { + await markFailed(row.id, e.message || 'invalid endpoint url'); + return; + } + await new Promise((resolve) => { - let url; - try { url = new URL(row.endpoint); } - catch (e) { - markFailed(row.id, 'invalid endpoint url').finally(resolve); - return; - } const lib = url.protocol === 'https:' ? https : http; const req = lib.request({ method: 'POST', diff --git a/server/src/hl7/server.js b/server/src/hl7/server.js index 9af9621..7a6cc61 100644 --- a/server/src/hl7/server.js +++ b/server/src/hl7/server.js @@ -64,7 +64,7 @@ function start({ config, logger }) { parsed = parseMessage(raw); } catch (e) { logger.warn({ err: e.message }, 'mllp parse failed'); - const nack = buildAck({ message_control_id: 'UNKNOWN' }, 'AR', e.message); + const nack = buildAck({ message_control_id: 'UNKNOWN' }, 'AR', 'Message parse failure'); socket.write(frame(nack)); continue; } @@ -97,8 +97,8 @@ function start({ config, logger }) { logger.info({ msgId: parsed.message_control_id, processed: result.processed, patientId: result.patientId, labCount: result.labCount }, 'mllp ingested'); } catch (e) { - logger.error({ err: e.stack }, 'mllp ingest threw'); - const nack = buildAck(parsed, 'AE', e.message); + logger.error({ err: e }, 'mllp ingest threw'); + const nack = buildAck(parsed, 'AE', 'Internal processing error'); socket.write(frame(nack)); } } diff --git a/server/src/index.js b/server/src/index.js index 7923909..0c4c50a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -54,7 +54,17 @@ async function build() { pool.init(config, app.log); - await app.register(cors, { origin: true, credentials: true }); + const allowedOrigins = (config.CORS_ALLOWED_ORIGINS || '') + .split(',').map(s => s.trim()).filter(Boolean); + await app.register(cors, { + origin: allowedOrigins.length > 0 + ? (origin, cb) => { + if (!origin || allowedOrigins.includes(origin)) cb(null, true); + else cb(new Error('CORS origin rejected'), false); + } + : config.NODE_ENV === 'development', + credentials: true, + }); await app.register(helmet, { contentSecurityPolicy: false, // SPA-served separately; FHIR clients break with strict CSP }); @@ -91,7 +101,7 @@ async function build() { } req.log.error({ err }, 'unhandled error'); reply.code(err.statusCode || 500).send({ - error: { code: 'internal_error', message: err.message }, + error: { code: 'internal_error', message: 'An unexpected error occurred' }, }); }); diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index 1d6cc1a..381c5d2 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -24,9 +24,11 @@ function makeAuthHook(config) { return async function authHook(req) { if (req.routeOptions?.config?.public) return; const header = req.headers['authorization'] || ''; - const m = header.match(/^Bearer\s+(.+)$/i); - if (!m) throw errors.unauthorized('Missing Bearer token'); - const raw = m[1]; + if (!header.toLowerCase().startsWith('bearer ')) { + throw errors.unauthorized('Missing Bearer token'); + } + const raw = header.slice(header.indexOf(' ') + 1).trim(); + if (!raw) throw errors.unauthorized('Missing Bearer token'); // Heuristic: native JWT contains exactly two dots and base64url segments; // SMART opaque tokens are a single base64url string. Try JWT first if it @@ -72,7 +74,7 @@ function makeAuthHook(config) { }; } catch (e) { if (e.statusCode) throw e; - throw errors.unauthorized('Invalid token: ' + e.message); + throw errors.unauthorized('Invalid token'); } }; } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index d5bae77..974b9c6 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -13,7 +13,7 @@ module.exports = async function authRoutes(app, opts) { const { config } = opts; // ----- POST /auth/login (local password) ----- - app.post('/auth/login', { config: { public: true } }, async (req) => { + app.post('/auth/login', { config: { public: true, rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => { const body = z.object({ email: z.string().email(), password: z.string().min(1), @@ -30,7 +30,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/mfa/verify ----- - app.post('/auth/mfa/verify', { config: { public: true } }, async (req) => { + app.post('/auth/mfa/verify', { config: { public: true, rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => { const body = z.object({ challengeId: z.string().uuid(), code: z.string().min(6).max(20), @@ -46,7 +46,7 @@ module.exports = async function authRoutes(app, opts) { }); // ----- POST /auth/refresh ----- - app.post('/auth/refresh', { config: { public: true } }, async (req) => { + app.post('/auth/refresh', { config: { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req) => { const body = z.object({ refresh: z.string().min(10) }).parse(req.body); return withTransaction({}, async (client) => { return authService.refresh(client, config, { @@ -175,11 +175,12 @@ module.exports = async function authRoutes(app, opts) { // =========================================================== if (config.SAML_ENABLED) { samlMod.init(config); - app.get('/auth/saml/login', { config: { public: true } }, async (req, reply) => { - const url = await samlMod.buildLoginUrl(req.query?.relay || '/'); + app.get('/auth/saml/login', { config: { public: true, rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req, reply) => { + const relay = sanitizeRedirectPath(req.query?.relay || '/'); + const url = await samlMod.buildLoginUrl(relay); return reply.redirect(url); }); - app.post('/auth/saml/callback', { config: { public: true } }, async (req, reply) => { + app.post('/auth/saml/callback', { config: { public: true, rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req, reply) => { const profile = await samlMod.validatePostResponse(req.body?.SAMLResponse, req.body); const attrs = samlMod.extractAttributes(profile, config); const orgId = config.HL7_DEFAULT_ORG_ID; @@ -197,7 +198,11 @@ module.exports = async function authRoutes(app, opts) { ip: req.ip, userAgent: req.headers['user-agent'], }); }); - const target = (req.body?.RelayState || '/') + `#access=${encodeURIComponent(session.access)}`; + const target = '/'; + reply.setCookie('transtrack_access', session.access, { + path: '/', httpOnly: true, secure: config.NODE_ENV === 'production', + sameSite: 'Lax', maxAge: config.JWT_ACCESS_TTL_SECONDS, + }); return reply.redirect(target); }); } @@ -209,13 +214,13 @@ module.exports = async function authRoutes(app, opts) { await oidcMod.init(config); const stateStore = new Map(); // dev-only; production should use redis/db - app.get('/auth/oidc/login', { config: { public: true } }, async (req, reply) => { + app.get('/auth/oidc/login', { config: { public: true, rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req, reply) => { const a = oidcMod.buildAuthRequest(); stateStore.set(a.state, a); return reply.redirect(a.url); }); - app.get('/auth/oidc/callback', { config: { public: true } }, async (req, reply) => { + app.get('/auth/oidc/callback', { config: { public: true, rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req, reply) => { const expected = stateStore.get(req.query.state); if (!expected) throw errors.badRequest('Invalid OIDC state'); stateStore.delete(req.query.state); @@ -236,10 +241,24 @@ module.exports = async function authRoutes(app, opts) { ip: req.ip, userAgent: req.headers['user-agent'], }); }); - return reply.redirect('/#access=' + encodeURIComponent(session.access)); + reply.setCookie('transtrack_access', session.access, { + path: '/', httpOnly: true, secure: config.NODE_ENV === 'production', + sameSite: 'Lax', maxAge: config.JWT_ACCESS_TTL_SECONDS, + }); + return reply.redirect('/'); }); } + /** + * Prevent open-redirect: only allow same-origin paths (starts with /, + * does not start with // or contain protocol scheme). + */ + function sanitizeRedirectPath(input) { + const s = String(input || '/'); + if (s.startsWith('/') && !s.startsWith('//') && !/^\/[\\@]/.test(s) && !s.includes(':')) return s; + return '/'; + } + // ----- GET /auth/me ----- app.get('/auth/me', async (req) => { if (!req.auth) throw errors.unauthorized(); diff --git a/server/src/routes/cds.js b/server/src/routes/cds.js index b95b1d7..20bbf09 100644 --- a/server/src/routes/cds.js +++ b/server/src/routes/cds.js @@ -19,7 +19,7 @@ require('../cds/services'); // side-effect: register built-in services module.exports = async function cdsRoutes(app) { app.get('/cds-services', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 60, timeWindow: '1 minute' } } }, async () => ({ services: registry.list() })); app.post('/cds-services/:id', async (req, reply) => { @@ -82,7 +82,11 @@ module.exports = async function cdsRoutes(app) { app.post('/cds-services/:id/feedback', async (req) => { // Per CDS Hooks 1.1, feedback informs the CDS service about user actions. // We accept and acknowledge; production deployments use this to tune. - req.log.info({ id: req.params.id, feedback: req.body }, 'cds feedback'); + const fb = req.body || {}; + req.log.info({ + id: req.params.id, + outcomeCount: Array.isArray(fb.feedback) ? fb.feedback.length : 0, + }, 'cds feedback'); return { acknowledged: true }; }); }; diff --git a/server/src/routes/health.js b/server/src/routes/health.js index d8e17d6..32a5a82 100644 --- a/server/src/routes/health.js +++ b/server/src/routes/health.js @@ -3,12 +3,12 @@ const { getPool } = require('../db/pool'); module.exports = async function healthRoutes(app) { - app.get('/health', { config: { public: true } }, async () => ({ + app.get('/health', { config: { public: true, rateLimit: { max: 120, timeWindow: '1 minute' } } }, async () => ({ status: 'ok', time: new Date().toISOString(), })); - app.get('/ready', { config: { public: true } }, async (_req, reply) => { + app.get('/ready', { config: { public: true, rateLimit: { max: 120, timeWindow: '1 minute' } } }, async (_req, reply) => { try { await getPool().query('SELECT 1'); return { status: 'ready', time: new Date().toISOString() }; diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js index 549c3bd..cb9115e 100644 --- a/server/src/routes/integrations.js +++ b/server/src/routes/integrations.js @@ -63,10 +63,6 @@ module.exports = async function integrationRoutes(app, opts) { app.get('/integrations/epic/status', async () => { return { enabled: !!(config.EPIC_SANDBOX_CLIENT_ID && config.EPIC_PRIVATE_KEY_FILE), - tokenUrl: config.EPIC_TOKEN_URL || epic.DEFAULT_TOKEN_URL, - fhirBase: config.EPIC_FHIR_BASE || epic.DEFAULT_FHIR_BASE, - defaultPatientId: - config.EPIC_DEFAULT_PATIENT_ID || 'erXuFYUfucBZaryVksYEcMg3', modes: ['bundle', 'server-fetch'], }; }); diff --git a/server/src/routes/smart.js b/server/src/routes/smart.js index 961f1a6..82c435e 100644 --- a/server/src/routes/smart.js +++ b/server/src/routes/smart.js @@ -43,7 +43,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Discovery ---------------------------------------------------------- app.get('/.well-known/smart-configuration', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (_req, reply) => { reply.type('application/json'); return { @@ -93,7 +93,7 @@ module.exports = async function smartRoutes(app, opts) { // Also publish the SMART config under the FHIR base, per the spec. app.get('/fhir/.well-known/smart-configuration', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (req, reply) => { const handler = app.routeIndex ? app.routeIndex.find(r => r.path === '/.well-known/smart-configuration')?.handler @@ -104,7 +104,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Authorization endpoint -------------------------------------------- app.get('/oauth2/authorize', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req, reply) => { const q = z.object({ response_type: z.literal('code'), @@ -150,7 +150,7 @@ module.exports = async function smartRoutes(app, opts) { }); app.post('/oauth2/authorize', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 15, timeWindow: '1 minute' } } }, async (req, reply) => { // Form post from the consent screen — the API caller is expected to // have presented some authentication challenge (the username/password @@ -166,10 +166,8 @@ module.exports = async function smartRoutes(app, opts) { nonce: z.string().optional(), launch_patient: z.string().optional(), launch_encounter: z.string().optional(), - // Either both username+password OR a pre-authenticated user_id (admin override) username: z.string().optional(), password: z.string().optional(), - user_id: z.string().uuid().optional(), decision: z.enum(['approve', 'deny']).default('approve'), }).parse(req.body || {}); @@ -184,9 +182,8 @@ module.exports = async function smartRoutes(app, opts) { return; } - let userId = body.user_id || null; - if (!userId) { - // Authenticate via username/password against the org's user store + let userId = null; + { if (!body.username || !body.password) { throw errors.unauthorized('username and password required'); } @@ -227,7 +224,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Token endpoint ----------------------------------------------------- app.post('/oauth2/token', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req, reply) => { reply.header('Cache-Control', 'no-store'); reply.header('Pragma', 'no-cache'); @@ -239,9 +236,8 @@ module.exports = async function smartRoutes(app, opts) { let basicClientId = null; let basicSecret = null; const auth = req.headers.authorization || ''; - const m = auth.match(/^Basic\s+(.+)$/i); - if (m) { - const decoded = Buffer.from(m[1], 'base64').toString('utf8'); + if (auth.toLowerCase().startsWith('basic ')) { + const decoded = Buffer.from(auth.slice(6).trim(), 'base64').toString('utf8'); const colon = decoded.indexOf(':'); if (colon > 0) { basicClientId = decoded.slice(0, colon); @@ -369,7 +365,7 @@ module.exports = async function smartRoutes(app, opts) { // ----- Introspection (RFC 7662) ------------------------------------------ app.post('/oauth2/introspect', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req) => { const data = z.object({ token: z.string().min(1) }).parse(req.body || {}); const found = await tokens.lookupAccess(data.token); @@ -381,13 +377,11 @@ module.exports = async function smartRoutes(app, opts) { token_type: 'Bearer', exp: Math.floor(new Date(found.expiresAt).getTime() / 1000), sub: found.userId, - org_id: found.orgId, - ...found.launchContext, }; }); app.post('/oauth2/revoke', - { config: { public: true } }, + { config: { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req, reply) => { const data = z.object({ token: z.string().min(1) }).parse(req.body || {}); await tokens.revoke(data.token); diff --git a/server/src/services/vendorProfileService.js b/server/src/services/vendorProfileService.js index dc3dd1c..6f65a09 100644 --- a/server/src/services/vendorProfileService.js +++ b/server/src/services/vendorProfileService.js @@ -10,6 +10,17 @@ const { withTransaction, query } = require('../db/pool'); * must be cheap. We cache the active profiles per org for 60 seconds. */ +/** + * Reject patterns with nested quantifiers or backreferences that + * can cause catastrophic backtracking (ReDoS). + */ +function isSafeRegex(pattern) { + if (typeof pattern !== 'string' || pattern.length > 200) return false; + if (/(\*|\+|\{)\s*(\*|\+|\{)/.test(pattern)) return false; + if (/(\.\*){3,}/.test(pattern)) return false; + return true; +} + const CACHE_TTL_MS = 60 * 1000; const cache = new Map(); // orgId -> { ts, profiles: [] } @@ -47,6 +58,7 @@ async function findFor(ctx, sendingApp, sendingFacility) { const haystack = `${sendingApp || ''}|${sendingFacility || ''}`; for (const p of profiles) { try { + if (!isSafeRegex(p.sending_app_pattern)) continue; const re = new RegExp(p.sending_app_pattern, 'i'); if (re.test(sendingApp || '') || re.test(haystack)) return p; } catch (_e) { diff --git a/server/src/smart/backendJwt.js b/server/src/smart/backendJwt.js index 44ad3d3..e03647f 100644 --- a/server/src/smart/backendJwt.js +++ b/server/src/smart/backendJwt.js @@ -18,7 +18,6 @@ const { createPublicKey, createVerify, verify: cryptoVerify } = require('crypto'); const https = require('https'); -const http = require('http'); const ALG_TO_HASH = { RS256: 'RSA-SHA256', @@ -34,9 +33,12 @@ function b64urlDecode(s) { } async function fetchJwks(uri) { + const parsed = new URL(uri); + if (parsed.protocol !== 'https:') { + throw new Error('jwks_uri must use HTTPS'); + } return new Promise((resolve, reject) => { - const lib = uri.startsWith('https:') ? https : http; - const req = lib.get(uri, (res) => { + const req = https.get(uri, (res) => { if (res.statusCode !== 200) { return reject(new Error(`jwks_uri returned ${res.statusCode}`)); } diff --git a/src/api/localClient.js b/src/api/localClient.js index f267d2f..1cec5c9 100644 --- a/src/api/localClient.js +++ b/src/api/localClient.js @@ -19,7 +19,7 @@ const mockClient = { }, mfa: { status: async () => ({ enrolled: false, backup_codes_remaining: 0 }), - beginEnrollment: async () => ({ secret_base32: 'JBSWY3DPEHPK3PXP', otpauth_url: 'otpauth://totp/Mock?secret=JBSWY3DPEHPK3PXP', backup_codes: [] }), + beginEnrollment: async () => ({ secret_base32: 'MOCK_DEV_ONLY_SECRET', otpauth_url: 'otpauth://totp/Mock?secret=MOCK_DEV_ONLY_SECRET', backup_codes: [] }), confirmEnrollment: async () => ({ ok: true, backup_codes: ['1111-2222','3333-4444'] }), verifyChallenge: async () => ({ ok: true, method: 'totp' }), regenerateBackupCodes: async () => ({ backup_codes: ['1111-2222','3333-4444'] }), diff --git a/src/api/remoteClient.js b/src/api/remoteClient.js index 19a693a..b4723a5 100644 --- a/src/api/remoteClient.js +++ b/src/api/remoteClient.js @@ -183,12 +183,25 @@ class RemoteClient { }; } +function validateBaseUrl(raw) { + if (!raw) return null; + try { + const url = new URL(raw); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return raw; + if (url.protocol !== 'https:') return null; + return raw; + } catch { + return null; + } +} + function resolveBaseUrl() { if (typeof window !== 'undefined' && window.transtrackConfig?.apiBaseUrl) { - return window.transtrackConfig.apiBaseUrl; + return validateBaseUrl(window.transtrackConfig.apiBaseUrl); } if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_TRANSTRACK_API_URL) { - return import.meta.env.VITE_TRANSTRACK_API_URL; + return validateBaseUrl(import.meta.env.VITE_TRANSTRACK_API_URL); } return null; } diff --git a/src/components/ui/sidebar.jsx b/src/components/ui/sidebar.jsx index bd30d0d..9cfb53a 100644 --- a/src/components/ui/sidebar.jsx +++ b/src/components/ui/sidebar.jsx @@ -63,7 +63,7 @@ const SidebarProvider = React.forwardRef(( } // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax; Secure` }, [setOpenProp, open]) // Helper to toggle the sidebar. diff --git a/src/index.html b/src/index.html index 69c9f77..5b4a87e 100644 --- a/src/index.html +++ b/src/index.html @@ -4,7 +4,7 @@ - + TransTrack - Transplant Waitlist Management diff --git a/src/pages/PatientDetails.jsx b/src/pages/PatientDetails.jsx index b1835bb..6d15795 100644 --- a/src/pages/PatientDetails.jsx +++ b/src/pages/PatientDetails.jsx @@ -298,10 +298,18 @@ export default function PatientDetails() {
Attached Documents
- {patient.document_urls.map((url, index) => ( + {patient.document_urls.map((url, index) => { + let safeUrl = '#'; + try { + const parsed = new URL(url, window.location.origin); + if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { + safeUrl = parsed.href; + } + } catch { /* invalid URL, keep # */ } + return ( Document {index + 1} - ))} + ); + })}
)} diff --git a/src/utils/index.js b/src/utils/index.js index 03f2f5c..570a6d4 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -107,7 +107,12 @@ export function isValidEmail(email) { } export function generateId() { - return Date.now().toString(36) + Math.random().toString(36).substr(2); + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + const arr = new Uint8Array(16); + crypto.getRandomValues(arr); + return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); } export function debounce(func, wait) {