fhirsmith 0.14.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/config-template.json +11 -0
- package/library/languages.js +15 -0
- package/library/same-origin.js +62 -0
- package/nginx.md +17 -9
- package/package.json +1 -1
- package/publisher/publisher-template.html +1 -0
- package/publisher/publisher.js +95 -42
- package/registry/registry.js +3 -1
- package/server.js +6 -1
- package/testing/readme.md +32 -0
- package/testing/render.js +270 -29
- package/testing/store.js +152 -8
- package/testing/testing.js +283 -9
- package/tx/cs/cs-snomed.js +3 -72
- package/tx/sct/editions.js +70 -0
- package/tx/workers/expand.js +2 -2
- package/tx/workers/translate.js +25 -1
- package/tx/workers/validate.js +9 -5
- package/tx/workers/worker.js +3 -35
- package/utilities/submit-test-report.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to Health Intersections FHIRsmith will be documented in this
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
|
|
9
|
+
## [0.14.1] - 2026-09-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- TestReport module: user administration - the administrator (`adminPassword`) manages users at /testing/admin/users, with rights to edit named links and/or delete reports
|
|
14
|
+
- TestReport module: named links - test script and participant URLs shown by name, maintained at /testing/admin/links
|
|
15
|
+
- TestReport module: the report list shows the test engine, client and server, the number of tests and the run length; a report page shows setup, each test with its operations and assertions, and teardown
|
|
16
|
+
- Test reports from the FHIRsmith build name the test modes they ran with
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Security: the publisher and testing web pages require a CSRF token on every form post (lusca), a SameSite=Lax session cookie, and a same-origin `Origin` on posts; the session id is regenerated on login
|
|
21
|
+
- Security: the publisher rate limits task actions (create, approve, delete, retry) and task output reads, as well as logins
|
|
22
|
+
- `server.trustProxy` now defaults to trusting only loopback, link-local and private-network proxies (was `true`, which let anyone reaching the port directly set their own client address and get round the rate limits). See nginx.md - a proxy elsewhere needs to be configured, and the proxy must pass on `Host` or `X-Forwarded-Host`, or form posts are refused
|
|
23
|
+
- $expand: the `displayLanguage` expansion parameter is written without spaces (`en,*;q=0`, not `en, *; q=0`)
|
|
24
|
+
- $translate: `result` is only true if at least one match is a translation - a `not-related-to` (R4: `disjoint`/`unmatched`) or `noMap` match is still returned, but `result` is false
|
|
25
|
+
- SNOMED CT edition names are held in one table (tx/sct/editions.js), used by the provider and the unknown code messages
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- $validate-code: when the code is not in the value set, it is looked up in the code system version the value set selects (`compose.include.version`), not the server's default version - so the version and display reported come from the right edition
|
|
30
|
+
- $validate-code: the unknown code message named the SNOMED CT test edition as "unknown"; it is now "Test Edition"
|
|
31
|
+
- Registry: the SNOMED CT test edition (`http://snomed.info/xsct/31000003106`) is labelled, rather than shown as `??`
|
|
32
|
+
|
|
33
|
+
### Tx Conformance Statement
|
|
34
|
+
|
|
35
|
+
FHIRsmith passed all 3585 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v6.10.4)
|
|
36
|
+
|
|
8
37
|
## [0.14.0] - 2026-09-27
|
|
9
38
|
|
|
10
39
|
### Added
|
package/config-template.json
CHANGED
|
@@ -88,6 +88,10 @@
|
|
|
88
88
|
"cookieSecure": true,
|
|
89
89
|
// failed-login attempts allowed per IP per 15 minutes (default 20)
|
|
90
90
|
"loginAttemptsPerWindow": 20,
|
|
91
|
+
// per IP address per minute: task actions (create, approve, delete, retry), and reads
|
|
92
|
+
// of task output, history and QA pages and files
|
|
93
|
+
"taskActionsPerMinute": 30,
|
|
94
|
+
"taskReadsPerMinute": 300,
|
|
91
95
|
"workspaceRoot": "/absolute/path/to/workspaces",
|
|
92
96
|
"igPublisherTimeoutMinutes": 60,
|
|
93
97
|
// where to put files the IG Publisher leaves in the web folder that are too big for GitHub
|
|
@@ -261,6 +265,13 @@
|
|
|
261
265
|
"tokenHeader": "Authorization",
|
|
262
266
|
// DELETE of a report needs this token, in the same header. No adminToken, no deletes
|
|
263
267
|
// "adminToken": "<!-- secret -->",
|
|
268
|
+
// the web administration pages: log in as 'admin' with this password to manage users
|
|
269
|
+
// (who can edit the named links and/or delete reports). No adminPassword, no administrator
|
|
270
|
+
// "adminPassword": "<!-- secret -->",
|
|
271
|
+
// signs the login cookie; without it logins don't survive a restart
|
|
272
|
+
// "sessionSecret": "<!-- random string -->",
|
|
273
|
+
// the login cookie is HTTPS-only unless this is false (e.g. for local testing over http)
|
|
274
|
+
// "cookieSecure": true,
|
|
264
275
|
// bytes
|
|
265
276
|
"maxSize": 512000,
|
|
266
277
|
// reports per IP address per window; max 0 turns the limit off
|
package/library/languages.js
CHANGED
|
@@ -562,6 +562,21 @@ class Languages {
|
|
|
562
562
|
}
|
|
563
563
|
return parts.join(', ');
|
|
564
564
|
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* The languages as they were asked for, in the form a displayLanguage parameter takes
|
|
568
|
+
* (the Accept-Language syntax: "en,it;q=0.8,*"). Unlike asString, which is for reading
|
|
569
|
+
* in messages, there are no spaces; unlike toString, implicit languages are left out.
|
|
570
|
+
*/
|
|
571
|
+
asParameter() {
|
|
572
|
+
return this.languages.filter(lang => !lang.implicit).map(lang => {
|
|
573
|
+
if (lang.quality === undefined || lang.quality === 1.0) {
|
|
574
|
+
return lang.code;
|
|
575
|
+
} else {
|
|
576
|
+
return `${lang.code};q=${lang.quality}`;
|
|
577
|
+
}
|
|
578
|
+
}).join(',');
|
|
579
|
+
}
|
|
565
580
|
}
|
|
566
581
|
|
|
567
582
|
/**
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse form posts that come from another site (CSRF).
|
|
3
|
+
*
|
|
4
|
+
* The session-cookie modules (publisher, testing) take state-changing actions on a POST
|
|
5
|
+
* from a logged-in browser. A page on another site can make the browser send such a
|
|
6
|
+
* POST, cookie and all, unless something stops it. Two things do here:
|
|
7
|
+
*
|
|
8
|
+
* - the session cookie is SameSite=Lax, so current browsers leave it off a POST that
|
|
9
|
+
* starts on another site;
|
|
10
|
+
* - this check: every browser sends an Origin header on a cross-site POST (and on
|
|
11
|
+
* same-site ones), so a POST whose Origin names another host is refused, whatever
|
|
12
|
+
* the browser did with the cookie.
|
|
13
|
+
*
|
|
14
|
+
* A request with no Origin at all (curl, older browsers on same-origin posts) is let
|
|
15
|
+
* through - it can't be a browser being driven by another site's page. Behind a proxy,
|
|
16
|
+
* the proxy must pass on Host or X-Forwarded-Host, or every post is refused.
|
|
17
|
+
*
|
|
18
|
+
* @module library/same-origin
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Is this request allowed by the same-origin rule?
|
|
25
|
+
* @param {import('express').Request} req
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isSameOrigin(req) {
|
|
29
|
+
if (SAFE.has(req.method)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const origin = req.get('Origin');
|
|
33
|
+
if (!origin) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
let host;
|
|
37
|
+
try {
|
|
38
|
+
// "null" (a sandboxed page, a file: URL, some redirects) does not parse, and is refused
|
|
39
|
+
host = new URL(origin).host;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return host === req.get('host') || host === req.get('x-forwarded-host');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Middleware that refuses cross-site requests that change things.
|
|
48
|
+
* @param {function(req, res): void} [reject] - sends the refusal; default a plain 403
|
|
49
|
+
*/
|
|
50
|
+
function requireSameOrigin(reject) {
|
|
51
|
+
return (req, res, next) => {
|
|
52
|
+
if (isSameOrigin(req)) {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
if (reject) {
|
|
56
|
+
return reject(req, res);
|
|
57
|
+
}
|
|
58
|
+
res.status(403).type('text/plain').send('Cross-site form posts are not accepted');
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { isSameOrigin, requireSameOrigin };
|
package/nginx.md
CHANGED
|
@@ -13,13 +13,13 @@ public servers (tx.fhir.org, packages.fhir.org and so on) use.
|
|
|
13
13
|
|
|
14
14
|
### Headers to pass to the server
|
|
15
15
|
|
|
16
|
-
FHIRsmith runs Express with `trust proxy` enabled (`server.trustProxy` in config.json
|
|
17
|
-
default
|
|
18
|
-
`X-Forwarded-*` headers, so nginx has to set them:
|
|
16
|
+
FHIRsmith runs Express with `trust proxy` enabled (`server.trustProxy` in config.json; by
|
|
17
|
+
default it trusts proxies on the same host or a private network). That means it takes the
|
|
18
|
+
client address, scheme and host from the `X-Forwarded-*` headers, so nginx has to set them:
|
|
19
19
|
|
|
20
20
|
| Header | nginx value | What FHIRsmith uses it for |
|
|
21
21
|
|---|---|---|
|
|
22
|
-
| `Host` | `$host` | The base URL in the CapabilityStatement, TerminologyCapabilities and other self-references. It is taken from the host the client asked for |
|
|
22
|
+
| `Host` | `$host` | The base URL in the CapabilityStatement, TerminologyCapabilities and other self-references. It is taken from the host the client asked for. The publisher and testing modules also check the `Origin` of every form post against it, and refuse posts from anywhere else - so if nginx sends a different `Host` (e.g. `proxy_pass` with the default `$proxy_host`), every login and form fails with a 403 |
|
|
23
23
|
| `X-Forwarded-Proto` | `$scheme` | The scheme of those same base URLs, and the `Secure` session cookie used by the publisher and token modules. If this header is missing, an HTTPS server advertises `http://` URLs and publisher login fails silently: you are sent back to the login page, still logged out |
|
|
24
24
|
| `X-Forwarded-For` | `$proxy_add_x_forwarded_for` | The client IP address in audit and security logs (`req.ip`). If this header is missing, every request is logged as coming from `127.0.0.1` |
|
|
25
25
|
| `X-Request-Id` | `$request_id` | Optional. FHIRsmith echoes it back on the response, so a client's report can be matched to a line in the nginx log (add `$request_id` to your `log_format`) |
|
|
@@ -49,11 +49,19 @@ them:
|
|
|
49
49
|
|
|
50
50
|
### Trusting the proxy
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
By default FHIRsmith believes the `X-Forwarded-*` headers only when the request comes from
|
|
53
|
+
a loopback, link-local or private address (`"loopback, linklocal, uniquelocal"`) - nginx on
|
|
54
|
+
the same machine, or on the same docker or private network. A request straight from the
|
|
55
|
+
internet can't set its own client address, so it can't get round the rate limits on logins,
|
|
56
|
+
report submission and the publisher's task actions.
|
|
57
|
+
|
|
58
|
+
If your proxy is somewhere else (a cloud load balancer with a public address, say), set
|
|
59
|
+
`server.trustProxy` to its address, or to the number of proxy hops; any value Express's
|
|
60
|
+
`trust proxy` setting accepts will work. If you run FHIRsmith without a proxy, set it to
|
|
61
|
+
`false`. Avoid `true`: it believes any `X-Forwarded-For`, so anyone who can reach the
|
|
62
|
+
FHIRsmith port directly can name their own IP address, and express-rate-limit logs
|
|
63
|
+
`ERR_ERL_PERMISSIVE_TRUST_PROXY` to say so. Either way, firewalling the port so that only
|
|
64
|
+
nginx can reach it is still a good idea.
|
|
57
65
|
|
|
58
66
|
### Upstream connections
|
|
59
67
|
|
package/package.json
CHANGED
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
<a href="/publisher" style="color: gold">Publisher Home</a> |
|
|
92
92
|
<a href="/publisher/tasks" style="color: gold">Tasks</a> |
|
|
93
93
|
<form method="[%loginAction%]" action="/publisher/[%loginPath%]" style="display:inline">
|
|
94
|
+
<input type="hidden" name="_csrf" value="[%csrfToken%]"/>
|
|
94
95
|
<button type="submit" style="background:none; border:none; color:gold; cursor:pointer; font:inherit; padding:0">[%loginTitle%]</button>
|
|
95
96
|
</form>
|
|
96
97
|
</div> <!-- /container -->
|
package/publisher/publisher.js
CHANGED
|
@@ -7,10 +7,12 @@ const githubRelease = require('./github-release');
|
|
|
7
7
|
const Database = require('sqlite3').Database;
|
|
8
8
|
const bcrypt = require('bcrypt');
|
|
9
9
|
const session = require('express-session');
|
|
10
|
+
const lusca = require('lusca');
|
|
10
11
|
const rateLimit = require('express-rate-limit');
|
|
11
12
|
const crypto = require('crypto');
|
|
12
13
|
const folders = require('../library/folder-setup');
|
|
13
14
|
const escape = require('escape-html');
|
|
15
|
+
const { requireSameOrigin } = require('../library/same-origin');
|
|
14
16
|
const {Utilities} = require("../library/utilities");
|
|
15
17
|
|
|
16
18
|
// GitHub refuses any file larger than this at the pre-receive hook, and the rejection takes down
|
|
@@ -59,14 +61,29 @@ class PublisherModule {
|
|
|
59
61
|
// plain HTTP - without it the browser will not send the cookie back and login
|
|
60
62
|
// appears to succeed and then silently do nothing.
|
|
61
63
|
secure: this.config.cookieSecure ?? true,
|
|
64
|
+
// CSRF: the browser leaves the cookie off a POST that starts on another site. That,
|
|
65
|
+
// with the Origin check below, is what stops another site's page from driving a
|
|
66
|
+
// logged-in browser to create, approve or delete tasks, or to add users
|
|
67
|
+
sameSite: 'lax',
|
|
68
|
+
httpOnly: true,
|
|
62
69
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
|
63
70
|
}
|
|
64
71
|
// Not using SQLiteStore to avoid the database conflict
|
|
65
72
|
}));
|
|
66
73
|
|
|
74
|
+
// every POST must come from one of our own pages - see library/same-origin.js
|
|
75
|
+
this.router.use(requireSameOrigin());
|
|
76
|
+
|
|
67
77
|
// Parse form data
|
|
68
78
|
this.router.use(express.urlencoded({ extended: true }));
|
|
69
79
|
|
|
80
|
+
// CSRF tokens (lusca): every form carries the session's token in a hidden _csrf field,
|
|
81
|
+
// and every POST route has this.csrf in it, written out so static analysis can see it.
|
|
82
|
+
// Pages only make a token for someone logged in (making one stores a secret in the
|
|
83
|
+
// session, and anonymous visitors should not get sessions); the login page always does
|
|
84
|
+
this.csrf = lusca.csrf({ key: '_csrf' });
|
|
85
|
+
this.csrfIfLoggedIn = (req, res, next) => req.session && req.session.userId ? this.csrf(req, res, next) : next();
|
|
86
|
+
|
|
70
87
|
// Set up routes
|
|
71
88
|
this.setupRoutes();
|
|
72
89
|
|
|
@@ -308,7 +325,7 @@ class PublisherModule {
|
|
|
308
325
|
|
|
309
326
|
setupRoutes() {
|
|
310
327
|
// Main dashboard
|
|
311
|
-
this.router.get('/', this.renderDashboard.bind(this));
|
|
328
|
+
this.router.get('/', this.csrfIfLoggedIn, this.renderDashboard.bind(this));
|
|
312
329
|
|
|
313
330
|
// Authentication. The login post is rate limited: besides slowing down guessing, it
|
|
314
331
|
// is the one unauthenticated route that runs bcrypt, and bcrypt.compare holds a libuv
|
|
@@ -322,20 +339,39 @@ class PublisherModule {
|
|
|
322
339
|
legacyHeaders: false
|
|
323
340
|
});
|
|
324
341
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
342
|
+
// Task actions delete build output from disk or queue new builds: a logged-in user can't
|
|
343
|
+
// be allowed to hammer them either
|
|
344
|
+
const taskActionLimiter = rateLimit({
|
|
345
|
+
windowMs: 60 * 1000,
|
|
346
|
+
max: this.config.taskActionsPerMinute ?? 30,
|
|
347
|
+
message: 'Too many task actions, please try again later.',
|
|
348
|
+
standardHeaders: true,
|
|
349
|
+
legacyHeaders: false
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// the task pages read build logs and QA output from disk
|
|
353
|
+
const taskReadLimiter = rateLimit({
|
|
354
|
+
windowMs: 60 * 1000,
|
|
355
|
+
max: this.config.taskReadsPerMinute ?? 300,
|
|
356
|
+
message: 'Too many requests, please try again later.',
|
|
357
|
+
standardHeaders: true,
|
|
358
|
+
legacyHeaders: false
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this.router.get('/login', this.csrf, this.renderLogin.bind(this));
|
|
362
|
+
this.router.post('/login', loginLimiter, this.csrf, this.handleLogin.bind(this));
|
|
363
|
+
this.router.post('/logout', this.csrf, this.handleLogout.bind(this));
|
|
328
364
|
|
|
329
365
|
// Tasks
|
|
330
|
-
this.router.get('/tasks', this.renderTasks.bind(this));
|
|
331
|
-
this.router.post('/tasks', this.requireAuth.bind(this), this.createTask.bind(this));
|
|
332
|
-
this.router.post('/tasks/:id/approve', this.requireAuth.bind(this), this.approveTask.bind(this));
|
|
333
|
-
this.router.post('/tasks/:id/delete', this.requireAuth.bind(this), this.deleteTask.bind(this));
|
|
334
|
-
this.router.post('/tasks/:id/retry', this.requireAuth.bind(this), this.retryTask.bind(this));
|
|
335
|
-
this.router.get('/tasks/:id/output', this.getTaskOutput.bind(this));
|
|
336
|
-
this.router.get('/tasks/:id/history', this.getTaskHistory.bind(this));
|
|
337
|
-
this.router.get('/tasks/:id/qa', this.getTaskQA.bind(this));
|
|
338
|
-
this.router.use('/tasks/:id/qa-files', (req, res, next) => {
|
|
366
|
+
this.router.get('/tasks', this.csrfIfLoggedIn, this.renderTasks.bind(this));
|
|
367
|
+
this.router.post('/tasks', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.createTask.bind(this));
|
|
368
|
+
this.router.post('/tasks/:id/approve', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.approveTask.bind(this));
|
|
369
|
+
this.router.post('/tasks/:id/delete', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.deleteTask.bind(this));
|
|
370
|
+
this.router.post('/tasks/:id/retry', taskActionLimiter, this.csrf, this.requireAuth.bind(this), this.retryTask.bind(this));
|
|
371
|
+
this.router.get('/tasks/:id/output', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskOutput.bind(this));
|
|
372
|
+
this.router.get('/tasks/:id/history', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskHistory.bind(this));
|
|
373
|
+
this.router.get('/tasks/:id/qa', taskReadLimiter, this.csrfIfLoggedIn, this.getTaskQA.bind(this));
|
|
374
|
+
this.router.use('/tasks/:id/qa-files', taskReadLimiter, (req, res, next) => {
|
|
339
375
|
const taskId = req.params.id;
|
|
340
376
|
this.getTask(taskId).then(task => {
|
|
341
377
|
if (!task || !task.local_folder) {
|
|
@@ -347,13 +383,27 @@ class PublisherModule {
|
|
|
347
383
|
});
|
|
348
384
|
|
|
349
385
|
// Admin routes
|
|
350
|
-
this.router.get('/admin/websites', this.requireAdmin.bind(this), this.renderWebsites.bind(this));
|
|
351
|
-
this.router.post('/admin/websites', this.requireAdmin.bind(this), this.createWebsite.bind(this));
|
|
352
|
-
this.router.get('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
|
|
353
|
-
this.router.post('/admin/websites/:id/edit', this.requireAdmin.bind(this), this.updateWebsite.bind(this));
|
|
354
|
-
this.router.get('/admin/users', this.requireAdmin.bind(this), this.renderUsers.bind(this));
|
|
355
|
-
this.router.post('/admin/users', this.requireAdmin.bind(this), this.createUser.bind(this));
|
|
356
|
-
this.router.post('/admin/permissions', this.requireAdmin.bind(this), this.updatePermissions.bind(this));
|
|
386
|
+
this.router.get('/admin/websites', this.csrf, this.requireAdmin.bind(this), this.renderWebsites.bind(this));
|
|
387
|
+
this.router.post('/admin/websites', this.csrf, this.requireAdmin.bind(this), this.createWebsite.bind(this));
|
|
388
|
+
this.router.get('/admin/websites/:id/edit', this.csrf, this.requireAdmin.bind(this), this.renderEditWebsite.bind(this));
|
|
389
|
+
this.router.post('/admin/websites/:id/edit', this.csrf, this.requireAdmin.bind(this), this.updateWebsite.bind(this));
|
|
390
|
+
this.router.get('/admin/users', this.csrf, this.requireAdmin.bind(this), this.renderUsers.bind(this));
|
|
391
|
+
this.router.post('/admin/users', this.csrf, this.requireAdmin.bind(this), this.createUser.bind(this));
|
|
392
|
+
this.router.post('/admin/permissions', this.csrf, this.requireAdmin.bind(this), this.updatePermissions.bind(this));
|
|
393
|
+
|
|
394
|
+
// lusca rejects a post with a missing or wrong token by passing on an error
|
|
395
|
+
this.router.use((err, req, res, next) => {
|
|
396
|
+
if (err && typeof err.message === 'string' && err.message.startsWith('CSRF token')) {
|
|
397
|
+
return res.status(403).type('text/plain').send('The form has expired or did not come from this site - go back, reload the page and try again');
|
|
398
|
+
}
|
|
399
|
+
next(err);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The hidden field every POST form carries: the session's CSRF token. */
|
|
404
|
+
csrfField(res) {
|
|
405
|
+
const token = res.locals && res.locals._csrf;
|
|
406
|
+
return token ? '<input type="hidden" name="_csrf" value="' + escape(token) + '">' : '';
|
|
357
407
|
}
|
|
358
408
|
|
|
359
409
|
// Background Task Processing
|
|
@@ -1476,7 +1526,7 @@ class PublisherModule {
|
|
|
1476
1526
|
content += '<a href="/publisher/admin/websites" class="btn btn-secondary me-2">Manage Websites</a>';
|
|
1477
1527
|
content += '<a href="/publisher/admin/users" class="btn btn-secondary">Manage Users</a>';
|
|
1478
1528
|
}
|
|
1479
|
-
content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">';
|
|
1529
|
+
content += '<form style="display: inline-block; margin-left: 10px;" method="post" action="/publisher/logout">' + this.csrfField(res);
|
|
1480
1530
|
content += '<button type="submit" class="btn btn-outline-secondary">' + escape(req.session.userName) + ' \u2014 Logout</button>';
|
|
1481
1531
|
content += '</form>';
|
|
1482
1532
|
content += '</div>';
|
|
@@ -1517,7 +1567,7 @@ class PublisherModule {
|
|
|
1517
1567
|
templateVars: {
|
|
1518
1568
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1519
1569
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1520
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1570
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1521
1571
|
}
|
|
1522
1572
|
});
|
|
1523
1573
|
|
|
@@ -1541,7 +1591,7 @@ class PublisherModule {
|
|
|
1541
1591
|
let content = '<div class="row justify-content-center">';
|
|
1542
1592
|
content += '<div class="col-md-6">';
|
|
1543
1593
|
content += '<h3>Login</h3>';
|
|
1544
|
-
content += '<form method="post" action="/publisher/login">';
|
|
1594
|
+
content += '<form method="post" action="/publisher/login">' + this.csrfField(res);
|
|
1545
1595
|
content += '<div class="mb-3">';
|
|
1546
1596
|
content += '<label for="login" class="form-label">Username</label>';
|
|
1547
1597
|
content += '<input type="text" class="form-control" id="login" name="login" required>';
|
|
@@ -1559,7 +1609,7 @@ class PublisherModule {
|
|
|
1559
1609
|
templateVars: {
|
|
1560
1610
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1561
1611
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1562
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1612
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1563
1613
|
}});
|
|
1564
1614
|
res.setHeader('Content-Type', 'text/html');
|
|
1565
1615
|
res.send(html);
|
|
@@ -1595,6 +1645,9 @@ class PublisherModule {
|
|
|
1595
1645
|
return res.redirect('/publisher/login?error=invalid');
|
|
1596
1646
|
}
|
|
1597
1647
|
|
|
1648
|
+
// a new session id on login, so an id planted in the browser before login
|
|
1649
|
+
// (session fixation) does not become a logged-in session
|
|
1650
|
+
await new Promise((resolve, reject) => req.session.regenerate(e => e ? reject(e) : resolve()));
|
|
1598
1651
|
req.session.userId = user.id;
|
|
1599
1652
|
req.session.userName = user.name;
|
|
1600
1653
|
req.session.isAdmin = user.is_admin;
|
|
@@ -1640,7 +1693,7 @@ class PublisherModule {
|
|
|
1640
1693
|
content += '<button class="btn btn-primary" onclick="document.getElementById(\'create-task-panel\').style.display = document.getElementById(\'create-task-panel\').style.display === \'none\' ? \'block\' : \'none\'">New Publication Task</button>';
|
|
1641
1694
|
content += '<div id="create-task-panel" style="display: none;" class="mt-3">';
|
|
1642
1695
|
content += '<h3>Create New Publication Task</h3>';
|
|
1643
|
-
content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">';
|
|
1696
|
+
content += '<form id="create-task-form" method="post" action="/publisher/tasks" class="row g-3">' + this.csrfField(res);
|
|
1644
1697
|
content += '<div class="col-md-3">';
|
|
1645
1698
|
content += '<label for="website_id" class="form-label">Target Website</label>';
|
|
1646
1699
|
content += '<select class="form-select" id="website_id" name="website_id" required>';
|
|
@@ -1718,7 +1771,7 @@ class PublisherModule {
|
|
|
1718
1771
|
content += '<a href="/publisher/tasks/' + task.id + '/qa-files/index.html" class="btn btn-sm btn-outline-secondary me-1">View IG</a>';
|
|
1719
1772
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-sm btn-outline-secondary me-1">View QA</a>';
|
|
1720
1773
|
if (canApprove) {
|
|
1721
|
-
content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">';
|
|
1774
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/approve" style="display: inline;">' + this.csrfField(res);
|
|
1722
1775
|
content += '<button type="submit" name="approve" class="btn btn-sm btn-success me-1">Approve</button>';
|
|
1723
1776
|
content += '</form>';
|
|
1724
1777
|
}
|
|
@@ -1735,13 +1788,13 @@ class PublisherModule {
|
|
|
1735
1788
|
}
|
|
1736
1789
|
|
|
1737
1790
|
if (canDelete) {
|
|
1738
|
-
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
|
|
1791
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">' + this.csrfField(res);
|
|
1739
1792
|
content += '<button type="submit" class="btn btn-sm btn-danger">Delete</button>';
|
|
1740
1793
|
content += '</form>';
|
|
1741
1794
|
}
|
|
1742
1795
|
|
|
1743
1796
|
if (req.session.userId && task.status === 'failed') {
|
|
1744
|
-
content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">';
|
|
1797
|
+
content += ' <form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;">' + this.csrfField(res);
|
|
1745
1798
|
content += '<button type="submit" class="btn btn-sm btn-warning">Retry</button>';
|
|
1746
1799
|
content += '</form>';
|
|
1747
1800
|
}
|
|
@@ -1761,7 +1814,7 @@ class PublisherModule {
|
|
|
1761
1814
|
templateVars: {
|
|
1762
1815
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
1763
1816
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
1764
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
1817
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
1765
1818
|
}});
|
|
1766
1819
|
res.setHeader('Content-Type', 'text/html');
|
|
1767
1820
|
res.send(html);
|
|
@@ -1951,7 +2004,7 @@ class PublisherModule {
|
|
|
1951
2004
|
res.redirect('/publisher/tasks');
|
|
1952
2005
|
} catch (error) {
|
|
1953
2006
|
this.logger.error('Error deleting task:', error);
|
|
1954
|
-
res.status(500).send('Failed to delete task: ' + error.message);
|
|
2007
|
+
res.status(500).type('text/plain').send('Failed to delete task: ' + escape(error.message));
|
|
1955
2008
|
}
|
|
1956
2009
|
} finally {
|
|
1957
2010
|
this.stats.countRequest('delete-task', Date.now() - start);
|
|
@@ -1982,7 +2035,7 @@ class PublisherModule {
|
|
|
1982
2035
|
res.redirect('/publisher/tasks/' + newTaskId + '/history');
|
|
1983
2036
|
} catch (error) {
|
|
1984
2037
|
this.logger.error('Error retrying task:', error);
|
|
1985
|
-
res.status(500).send('Failed to retry task: ' + error.message);
|
|
2038
|
+
res.status(500).type('text/plain').send('Failed to retry task: ' + escape(error.message));
|
|
1986
2039
|
}
|
|
1987
2040
|
} finally {
|
|
1988
2041
|
this.stats.countRequest('retry-task', Date.now() - start);
|
|
@@ -2093,7 +2146,7 @@ class PublisherModule {
|
|
|
2093
2146
|
templateVars: {
|
|
2094
2147
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2095
2148
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2096
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2149
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2097
2150
|
}});
|
|
2098
2151
|
res.setHeader('Content-Type', 'text/html');
|
|
2099
2152
|
res.send(html);
|
|
@@ -2310,7 +2363,7 @@ class PublisherModule {
|
|
|
2310
2363
|
content += '<a href="/publisher/tasks/' + task.id + '/qa" class="btn btn-outline-secondary me-2">View QA Report</a>';
|
|
2311
2364
|
}
|
|
2312
2365
|
if (req.session.userId && task.status === 'failed') {
|
|
2313
|
-
content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">';
|
|
2366
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/retry" style="display: inline;" class="me-2">' + this.csrfField(res);
|
|
2314
2367
|
content += '<button type="submit" class="btn btn-warning">Retry</button>';
|
|
2315
2368
|
content += '</form>';
|
|
2316
2369
|
}
|
|
@@ -2324,7 +2377,7 @@ class PublisherModule {
|
|
|
2324
2377
|
(detailIsPostApprovalFailed && req.session.isAdmin)
|
|
2325
2378
|
);
|
|
2326
2379
|
if (detailCanDelete) {
|
|
2327
|
-
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">';
|
|
2380
|
+
content += '<form method="post" action="/publisher/tasks/' + task.id + '/delete" style="display: inline;" class="me-2" onsubmit="return confirm(\'Delete task #' + task.id + ' and all its build output? This cannot be undone.\')">' + this.csrfField(res);
|
|
2328
2381
|
content += '<button type="submit" class="btn btn-danger">Delete</button>';
|
|
2329
2382
|
content += '</form>';
|
|
2330
2383
|
}
|
|
@@ -2335,7 +2388,7 @@ class PublisherModule {
|
|
|
2335
2388
|
templateVars: {
|
|
2336
2389
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2337
2390
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2338
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2391
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2339
2392
|
}});
|
|
2340
2393
|
res.setHeader('Content-Type', 'text/html');
|
|
2341
2394
|
res.send(html);
|
|
@@ -2356,7 +2409,7 @@ class PublisherModule {
|
|
|
2356
2409
|
if (!website) return res.status(404).send('Website not found');
|
|
2357
2410
|
|
|
2358
2411
|
let content = '<h3>Edit Website</h3>';
|
|
2359
|
-
content += '<form method="post" action="/publisher/admin/websites/' + website.id + '/edit" class="row g-3">';
|
|
2412
|
+
content += '<form method="post" action="/publisher/admin/websites/' + website.id + '/edit" class="row g-3">' + this.csrfField(res);
|
|
2360
2413
|
content += '<div class="col-md-4"><label class="form-label">Website Name</label>';
|
|
2361
2414
|
content += '<input type="text" class="form-control" name="name" value="' + escape(website.name) + '" required></div>';
|
|
2362
2415
|
content += '<div class="col-md-4"><label class="form-label">Local Folder</label>';
|
|
@@ -2376,7 +2429,7 @@ class PublisherModule {
|
|
|
2376
2429
|
content += '</form>';
|
|
2377
2430
|
|
|
2378
2431
|
const html = htmlServer.renderPage('publisher', 'Edit Website - FHIR Publisher', content, {
|
|
2379
|
-
templateVars: { loginTitle: (req.session.userName || '') + ' \u2014 Logout', loginPath: 'logout', loginAction: 'POST' }
|
|
2432
|
+
templateVars: { loginTitle: (req.session.userName || '') + ' \u2014 Logout', loginPath: 'logout', loginAction: 'POST', csrfToken: res.locals._csrf || '' }
|
|
2380
2433
|
});
|
|
2381
2434
|
res.setHeader('Content-Type', 'text/html');
|
|
2382
2435
|
res.send(html);
|
|
@@ -2427,7 +2480,7 @@ class PublisherModule {
|
|
|
2427
2480
|
content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-website-panel\').style.display = document.getElementById(\'add-website-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New Website</button>';
|
|
2428
2481
|
content += '<div id="add-website-panel" style="display: none;" class="mt-3">';
|
|
2429
2482
|
content += '<h3>Add New Website</h3>';
|
|
2430
|
-
content += '<form method="post" action="/publisher/admin/websites" class="row g-3">';
|
|
2483
|
+
content += '<form method="post" action="/publisher/admin/websites" class="row g-3">' + this.csrfField(res);
|
|
2431
2484
|
content += '<div class="col-md-4">';
|
|
2432
2485
|
content += '<label for="name" class="form-label">Website Name</label>';
|
|
2433
2486
|
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
@@ -2496,7 +2549,7 @@ class PublisherModule {
|
|
|
2496
2549
|
templateVars: {
|
|
2497
2550
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2498
2551
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2499
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2552
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2500
2553
|
}});
|
|
2501
2554
|
res.setHeader('Content-Type', 'text/html');
|
|
2502
2555
|
res.send(html);
|
|
@@ -2556,7 +2609,7 @@ class PublisherModule {
|
|
|
2556
2609
|
content += '<button class="btn btn-primary" onclick="document.getElementById(\'add-user-panel\').style.display = document.getElementById(\'add-user-panel\').style.display === \'none\' ? \'block\' : \'none\'">Add New User</button>';
|
|
2557
2610
|
content += '<div id="add-user-panel" style="display: none;" class="mt-3">';
|
|
2558
2611
|
content += '<h3>Add New User</h3>';
|
|
2559
|
-
content += '<form method="post" action="/publisher/admin/users" class="row g-3">';
|
|
2612
|
+
content += '<form method="post" action="/publisher/admin/users" class="row g-3">' + this.csrfField(res);
|
|
2560
2613
|
content += '<div class="col-md-3">';
|
|
2561
2614
|
content += '<label for="name" class="form-label">Full Name</label>';
|
|
2562
2615
|
content += '<input type="text" class="form-control" id="name" name="name" required>';
|
|
@@ -2601,7 +2654,7 @@ class PublisherModule {
|
|
|
2601
2654
|
if (websites.length === 0) {
|
|
2602
2655
|
content += '<p>No websites available for permission assignment.</p>';
|
|
2603
2656
|
} else {
|
|
2604
|
-
content += '<form method="post" action="/publisher/admin/permissions">';
|
|
2657
|
+
content += '<form method="post" action="/publisher/admin/permissions">' + this.csrfField(res);
|
|
2605
2658
|
content += '<input type="hidden" name="user_id" value="' + user.id + '">';
|
|
2606
2659
|
content += '<div class="permission-grid">';
|
|
2607
2660
|
content += '<div><strong>Website</strong></div>';
|
|
@@ -2638,7 +2691,7 @@ class PublisherModule {
|
|
|
2638
2691
|
templateVars: {
|
|
2639
2692
|
loginTitle: req.session.userId ? (req.session.userName + ' \u2014 Logout') : 'Login',
|
|
2640
2693
|
loginPath: req.session.userId ? "logout" : 'login',
|
|
2641
|
-
loginAction: req.session.userId ? "POST" : 'GET'
|
|
2694
|
+
loginAction: req.session.userId ? "POST" : 'GET', csrfToken: res.locals._csrf || ''
|
|
2642
2695
|
}});
|
|
2643
2696
|
res.setHeader('Content-Type', 'text/html');
|
|
2644
2697
|
res.send(html);
|
package/registry/registry.js
CHANGED
|
@@ -721,7 +721,8 @@ class RegistryModule {
|
|
|
721
721
|
* @returns {string} Formatted URL with edition description
|
|
722
722
|
*/
|
|
723
723
|
_describeSnomedEdition(url) {
|
|
724
|
-
|
|
724
|
+
// the test edition is published under xsct (http://snomed.info/xsct/31000003106/...)
|
|
725
|
+
if (!url.startsWith('http://snomed.info/sct') && !url.startsWith('http://snomed.info/xsct')) {
|
|
725
726
|
return url;
|
|
726
727
|
}
|
|
727
728
|
|
|
@@ -745,6 +746,7 @@ class RegistryModule {
|
|
|
745
746
|
// Match edition code to description
|
|
746
747
|
switch (editionCode) {
|
|
747
748
|
case '900000000000207008': edition = 'Intl'; break;
|
|
749
|
+
case '31000003106': edition = 'Test'; break; // tx-ecosystem test edition (xsct)
|
|
748
750
|
case '731000124108': edition = 'US'; break;
|
|
749
751
|
case '32506021000036107': edition = 'AU'; break;
|
|
750
752
|
case '449081005': edition = 'ES/Intl'; break;
|
package/server.js
CHANGED
|
@@ -79,7 +79,12 @@ const app = express();
|
|
|
79
79
|
|
|
80
80
|
// Behind nginx (or any reverse proxy): honor X-Forwarded-* so req.protocol and
|
|
81
81
|
// req.hostname reflect what the client actually requested, not the loopback hop.
|
|
82
|
-
|
|
82
|
+
// Only proxies on the same host or a private network are trusted by default. `true` would
|
|
83
|
+
// trust every X-Forwarded-For hop, so anyone who could reach the port directly could name
|
|
84
|
+
// their own IP address and walk around every rate limit (express-rate-limit refuses to be
|
|
85
|
+
// quiet about it: ERR_ERL_PERMISSIVE_TRUST_PROXY). See nginx.md.
|
|
86
|
+
const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal';
|
|
87
|
+
app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY);
|
|
83
88
|
|
|
84
89
|
const PORT = process.env.PORT || config.server.port || 3000;
|
|
85
90
|
|
package/testing/readme.md
CHANGED
|
@@ -78,6 +78,38 @@ Everything in a report comes from whoever sent it, so all of it is escaped, and
|
|
|
78
78
|
URLs become links. The narrative (`text.div`) is never rendered as HTML - it appears only as
|
|
79
79
|
text in the raw JSON.
|
|
80
80
|
|
|
81
|
+
## What the pages show
|
|
82
|
+
|
|
83
|
+
The list has a column each for the test engine, client and server participants (the client
|
|
84
|
+
column only appears once some report has a client; a participant of any other type is shown
|
|
85
|
+
as a server). **Tests** is the number of entries in `TestReport.test`. **Run Length** is how long the run took, from the earliest `test.period.start`
|
|
86
|
+
to the latest `test.period.end`; it is blank when the report's tests have no periods. Both are
|
|
87
|
+
worked out when the report is received, and for reports received before they existed, the
|
|
88
|
+
first time the server starts with this version.
|
|
89
|
+
|
|
90
|
+
Where a test script or participant URL, ignoring any `|version`, is one of the **named
|
|
91
|
+
links**, its name is shown instead of the URL - linked to the named link's link, with the
|
|
92
|
+
full URL in the tooltip and the version after the name.
|
|
93
|
+
|
|
94
|
+
## Administration
|
|
95
|
+
|
|
96
|
+
`/testing/login`. The administrator logs in as `admin` with the `adminPassword` from the
|
|
97
|
+
configuration, and manages the other users at `/testing/admin/users`. A user can have either
|
|
98
|
+
or both of two rights:
|
|
99
|
+
|
|
100
|
+
* **edit named links** - `/testing/admin/links`: the canonical URLs that are shown by name
|
|
101
|
+
* **delete reports** - a Delete button on each report's page
|
|
102
|
+
|
|
103
|
+
Logins work as the publisher's do: a session cookie (set `sessionSecret`, and `cookieSecure:
|
|
104
|
+
false` if the server is reached over plain http), bcrypt password hashes, and a rate limited
|
|
105
|
+
login (`loginAttemptsPerWindow`, default 20 per 15 minutes). Rights are checked on every
|
|
106
|
+
request, so changing or deleting a user takes effect at once. Every form post must come from
|
|
107
|
+
the server's own pages: each form carries a per-session CSRF token (lusca) that the post must
|
|
108
|
+
return, the cookie is SameSite=Lax, and a post whose `Origin` is another host is refused
|
|
109
|
+
(behind a proxy, that needs the proxy to pass on `Host` or `X-Forwarded-Host`). The session
|
|
110
|
+
and CSRF checks are only on the web pages: the FHIR API is authenticated by the header
|
|
111
|
+
token, and sets no cookie.
|
|
112
|
+
|
|
81
113
|
## Deleting
|
|
82
114
|
|
|
83
115
|
DELETE /testing/TestReport/{id}
|