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 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
@@ -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
@@ -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 `true`). That means it takes the client address, scheme and host from the
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
- With `trustProxy: true`, anyone who can reach the FHIRsmith port directly can forge
53
- `X-Forwarded-For`. FHIRsmith listens on all interfaces, so firewall the port so that only
54
- nginx can reach it. You can also set `server.trustProxy` to `"loopback"`, or to the proxy's
55
- address; any value that Express's `trust proxy` setting accepts will work. If you run
56
- FHIRsmith without a proxy, set it to `false`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "txVersion": "1.9.5-SNAPSHOT",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -91,6 +91,7 @@
91
91
  <a href="/publisher" style="color: gold">Publisher Home</a> &nbsp;|&nbsp;
92
92
  <a href="/publisher/tasks" style="color: gold">Tasks</a> &nbsp;|&nbsp;
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 -->
@@ -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
- this.router.get('/login', this.renderLogin.bind(this));
326
- this.router.post('/login', loginLimiter, this.handleLogin.bind(this));
327
- this.router.post('/logout', this.handleLogout.bind(this));
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);
@@ -721,7 +721,8 @@ class RegistryModule {
721
721
  * @returns {string} Formatted URL with edition description
722
722
  */
723
723
  _describeSnomedEdition(url) {
724
- if (!url.startsWith('http://snomed.info/sct')) {
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
- app.set('trust proxy', config.server.trustProxy ?? true);
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}