fhirsmith 0.16.1 → 0.17.0

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,53 @@ 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
+ ## [0.17.0] - 2026-10-17
9
+
10
+ An extensive security review, which hardened many things, but no actual securty notifications needed. Plus improvemnts to text search in terminology filters
11
+
12
+ ### Security
13
+
14
+ - HTML pages (all modules using the shared page template): content containing `` $` `` or `$'` - which HTML escaping leaves alone - made the template insert a copy of part of itself into the page, garbling it. No way to inject script through it was found, but the page is now built without that substitution
15
+ - Packages: a tarball whose package.json version was not entirely semver (e.g. `1.0.0/../../x`) was accepted, and its mirror file written outside the mirror folder. The version must now be whole semver, and the mirror file must be in the mirror folder
16
+ - Rendered resources: links to `javascript:`, `data:` or `vbscript:` URLs taken from resource content are neutralised
17
+ - tx: values from loaded packages are escaped (and URL-encoded in links) on the problems page, the resource tabs, and the operations form's list of value sets
18
+ - tx: CodeSystems, ValueSets and ConceptMaps whose narrative (or a contained resource's) has active content - script, event handler attributes, `javascript:` URLs, frames, objects, forms, svg and the like - are not loaded, and a warning says what was found. The Original Narrative tab also refuses such a narrative. The check is in `library/narrative-check.js` for other uses. The package value set and concept map caches are renamed so that they are rebuilt and re-checked once
19
+ - xig: the database is downloaded over https (it was http, so it could theoretically have been replaced in transit, and its narratives are shown as they are), and a redirect off https is refused
20
+ - Extension tracker: the profiles page put the resource types from submissions into a script without escaping, so anyone could submit data that ran script on that page. They are now escaped
21
+ - Extension tracker: submissions must carry the token the IG Publisher sends (`token` in the ext-tracker config; without it, no token is needed), and must look like what the IG Publisher sends - the expected properties only, codes, URLs, resource types and element paths where those belong, and limited sizes. Anything else is refused, and the refusal logged
22
+ - Registry: server and registry URLs from registry lists (which other people host) were put into links without escaping, so a listed URL could add script to the registry's pages; and links were made from any URL, including `javascript:`. Links are now escaped, and made only from http(s) URLs
23
+ - Registry: the crawler limits each server's response to `maxResponseMB` (default 100) once unzipped, so a server can't exhaust the registry's memory
24
+ - xig: POST /xig/update, which re-downloads the database (about 3 GB), needed no authentication. It now needs the `updateToken` from the xig config in an `x-update-token` header (or the form now on /xig/stats, which starts an update and returns straight away), and is disabled when no updateToken is configured
25
+ - xig: a search parameter given more than once, or a very long one, is handled (the first is used, and each is capped at 200 characters) instead of failing the page
26
+ - OCL: the configured token can see the private repos of its account, and what it finds is served to anyone through the terminology server. Sources and collections are now served only when they are public (`public_access` View or Edit), whether they are found by discovery or by `$resolveReference`; anything that can't be confirmed public is left out. The token's account should still be one that can see only public repos
27
+ - OCL: a repo path returned by OCL is only used if it is a relative path on the OCL server, so a request carrying the token can't be sent to another host
28
+ - Packages: the crawler limits what a package can cost it: the download (`maxDownloadMB`, default 250), the total unzipped (`maxUnpackedMB`, 4096), the files it reads (`maxFileMB`, 50) and the number of files (`maxFiles`, 250000), all in the packages `crawler` config. Packages are now unpacked as a stream, keeping only package.json, .index.json, ig.ini and one .json file at a time, so a decompression bomb can't exhaust memory
29
+
30
+ ### Added
31
+
32
+ - `TerminologyCapabilities.expansion.textFilter` describes how text filters match
33
+ - OCL: with a `token=` on the `ocl:` source line, canonical URLs are resolved through OCL's `$resolveReference` (ConceptMap source systems, ValueSet collections and their compose sources), rather than by searching the listings. Without a token nothing changes
34
+ - OCL: a source's latest released version is served as its default version, as OCL itself does; HEAD is still available as version `HEAD` (needs the token)
35
+ - README: what to do after upgrading Node.js (`npm rebuild`, or reinstall)
36
+
37
+ ### Changed
38
+
39
+ - Text filters on code systems loaded from files and packages ignore case, and accents on Latin, Greek and Cyrillic letters (`colera` finds `Cólera`), and a search of several words matches when each word starts a word of the display or of one designation, in any order (`diabetes tipo 2` finds `Diabetes mellitus tipo 2`). These matches rank below matches of the whole text, shorter displays first.
40
+ - `$expand` with a text filter returns the best matches first on code systems loaded from files and packages, and on OCL code systems, as it already did on SNOMED CT
41
+ - OCL: sources and collections are discovered from the global `/sources/` and `/collections/` listings (falling back to each organisation's), and only organisation-owned ones are served - repos owned by a user are not
42
+ - OCL: a source's mappings are fetched from `{source}/mappings/` instead of walking every concept
43
+
44
+ ### Fixed
45
+
46
+ - LOINC: text filter matches came in reverse description order (Z to A) when the expansion asked for them sorted. They now come in description order either way
47
+ - LOINC: an `is-a` filter left out the concept itself, so it gave the same codes as `descendent-of`. It now includes the concept, as FHIR defines `is-a`
48
+ - OCL: `$translate` never returned anything from OCL concept maps. The maps declared OCL repo paths as their source and target scopes, which aren't value sets, so every translation failed with not-found; and `NARROWER-THAN` / `BROADER-THAN` mappings had relationship codes that aren't FHIR codes. The scopes are gone, the map types become `source-is-narrower-than-target` / `source-is-broader-than-target` (and `NOT-SAME-AS` becomes `not-related-to`), and `Q-AND-A` and `CONCEPT-SET` mappings, which aren't translations, are left out
49
+ - tx: a package value set or concept map cache whose build was cut short (the server stopped part way through) was used as it was, missing whatever hadn't been written. A cache is now marked complete when it's built, and one without the mark is rebuilt - so existing caches are rebuilt once, on the first start of this version
50
+
51
+ ### Tx Conformance Statement
52
+
53
+ FHIRsmith v0.17.0 passed all 3630 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed+mimetypes+icd-11+closure, tests v1.9.6, runner v7.0.2)
54
+
8
55
  ## [0.16.1] - 2026-10-16
9
56
 
10
57
  ### Added
package/README.md CHANGED
@@ -14,7 +14,7 @@ This server provides a set of server-side services that are useful for the FHIR
14
14
  * [Package server](packages/readme.md) - **NPM-style FHIR package registry** with search, versioning, and downloads, consistent with the FHIR NPM Specification (as running at http://packages2.fhir.org/packages). Its API is described by an OpenAPI 3.1 spec at `/packages/openapi.json` (and `.yaml`), with a browsable reference at `/packages/openapi`
15
15
  * [XIG server](xig/readme.md) - **Comprehensive FHIR IG analytics** with resource breakdowns by version, authority, and realm (as running at http://packages2.fhir.org/packages)
16
16
  * [Publisher](publisher/readme.md) - FHIR publishing services (as running at [healthintersections.com.au](http://www.healthintersections.com.au/publisher))
17
- * [Testing](testing/readme.md) - **TestReport repository**: receives FHIR TestReports from TxTester and other test tools, with a FHIR API and web pages (as running at https://testing.fhir.org/testing). Its API is described by an OpenAPI 3.1 spec at `/testing/openapi.json` (and `.yaml`), with a browsable reference at `/testing/openapi`
17
+ * [Testing](testing/readme.md) - **TestReport repository**: receives FHIR TestReports from TxTester and other test tools, with a FHIR API and web pages (as running at https://npm.fhir.org/testing). Its API is described by an OpenAPI 3.1 spec at `/testing/openapi.json` (and `.yaml`), with a browsable reference at `/testing/openapi`
18
18
  * [VCL](vcl/readme.md) - **Parse VCL expressions** into FHIR ValueSet resources (as running at http://fhir.org/vcl)
19
19
  * (Coming) Token services
20
20
 
@@ -81,7 +81,7 @@ During development with a cloned repository, the data directory defaults to `[ro
81
81
  is provided by the host — see Deployment below.
82
82
 
83
83
  ### Prerequisites
84
- - Node.js 16+
84
+ - Node.js 24+
85
85
  - NPM or Yarn
86
86
  - Java 17+ (for FHIR validator, also for the test cases)
87
87
 
@@ -207,6 +207,15 @@ Or run it inline:
207
207
  FHIRSMITH_DATA_DIR=/var/lib/fhirsmith fhirsmith
208
208
  ```
209
209
 
210
+ ### Changing the Node.js version
211
+
212
+ Some of FHIRsmith's dependencies (the SQLite drivers) are native modules, built for the
213
+ version of Node.js that installed them. After upgrading Node.js, rebuild them - `npm rebuild`
214
+ in the FHIRsmith folder, or `npm rebuild -g fhirsmith` for a global install - or reinstall
215
+ FHIRsmith. Otherwise the server stops at startup with "compiled against a different Node.js
216
+ version". (With nvm, each Node.js version has its own global packages, so install FHIRsmith
217
+ again under the new version.) Docker images aren't affected.
218
+
210
219
  ### Docker Installation
211
220
 
212
221
  The server is available as a Docker image. Mount a host directory as the data directory:
@@ -339,30 +348,29 @@ GitHub Actions will automatically:
339
348
 
340
349
  or do it via a PR
341
350
 
342
- 4. Tag and push the release:
351
+ 4. Run the tx tests (`npx jest tests/tx/test-cases.test.js`), then tag and push the release:
343
352
  ```bash
344
- git tag vX.Y.Z
345
- git push origin vX.Y.Z
353
+ ./release.sh X.Y.Z
346
354
  ```
347
355
 
356
+ `gr.sh` checks that the release is ready - on `main` and pushed, the three versions in
357
+ `package.json` and `package-lock.json` match, `CHANGELOG.md` has a dated section with the
358
+ conformance statement, and `test-cases-report.json` passed, is from this version and is
359
+ newer than the last code change - then submits the report to
360
+ [npm.fhir.org](https://npm.fhir.org/testing) (set `FHIRSMITH_TESTING_TOKEN` if it needs a
361
+ token), and only if that is accepted, tags `vX.Y.Z` and pushes the tag.
362
+
363
+ The tx test run writes `test-cases-report.json` next to `test-cases-summary.txt` (both
364
+ are ignored by git). The report records the version in `package.json` when the tests
365
+ ran, so run the tx tests after step 2. To submit a report on its own (to a different
366
+ /testing server, say): `node utilities/submit-test-report.js [-server {url}] [-token {token}]`.
367
+
348
368
  5. Monitor the release:
349
369
  - Check [GitHub Actions](https://github.com/HealthIntersections/fhirsmith/actions) for the Release workflow
350
370
  - Verify the [GitHub Release](https://github.com/HealthIntersections/fhirsmith/releases) was created
351
371
  - Confirm Docker images are available at [GHCR](https://github.com/HealthIntersections/fhirsmith/pkgs/container/fhirsmith)
352
372
 
353
- 6. Submit the tx conformance report to [testing.fhir.org](https://testing.fhir.org/testing):
354
- ```bash
355
- node utilities/submit-test-report.js -token {token}
356
- ```
357
-
358
- The tx test run writes `test-cases-report.json` next to `test-cases-summary.txt` (both
359
- are ignored by git); this uploads it as a TestReport. The report records the version in
360
- `package.json` when the tests ran, so for it to name the release, run the tx tests
361
- after step 2 - the script warns if the report is from a different version, or from a
362
- snapshot. Use `-server {url}` to send it to a different /testing server; the token can
363
- also come from `FHIRSMITH_TESTING_TOKEN`.
364
-
365
- 7. Update `package.json` to have the next release version -SNAPSHOT
373
+ 6. Update `package.json` to have the next release version -SNAPSHOT
366
374
 
367
375
  **If a release fails:**
368
376
  - Delete the tag: `git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z`
@@ -61,7 +61,11 @@
61
61
  },
62
62
  "xig": {
63
63
  "enabled": false,
64
- "autoUpdate": true
64
+ "autoUpdate": true,
65
+ // shared secret for POST /xig/update, which re-downloads the database on demand. Send
66
+ // it in the x-update-token header. Omit this and the endpoint is disabled entirely -
67
+ // the scheduled update (autoUpdate) is unaffected either way.
68
+ "updateToken": "change-this-to-a-secure-random-string"
65
69
  },
66
70
  "packages": {
67
71
  "enabled": false,
@@ -73,7 +77,17 @@
73
77
  "crawlToken": "change-this-to-a-secure-random-string",
74
78
  "crawler": {
75
79
  "enabled": true,
76
- "schedule": "0 * * * *"
80
+ "schedule": "0 * * * *",
81
+ // limits on a package, so a hostile or broken one (a decompression bomb, say) costs
82
+ // a bounded amount. These are the defaults. maxDownloadMB: the package file;
83
+ // maxUnpackedMB: its files once unzipped (streamed, so this limits work, not
84
+ // memory); maxFileMB: a file the crawler reads (package.json, .index.json, ig.ini
85
+ // and each .json, for its url) - memory use is bounded by this; maxFiles: the number
86
+ // of files in a package
87
+ "maxDownloadMB": 250,
88
+ "maxUnpackedMB": 4096,
89
+ "maxFileMB": 50,
90
+ "maxFiles": 250000
77
91
  },
78
92
  // /packages/npm-check: checks any registry against the npm registry protocol. On by
79
93
  // default; the other settings are the defaults. "control": false stops the daily
@@ -92,7 +106,9 @@
92
106
  "crawlInterval": 30,
93
107
  "timeout": 30000,
94
108
  "userAgent": "YourServer/1.0",
95
- "apiKeys": {}
109
+ "apiKeys": {},
110
+ // the most a crawled server's response can be, once unzipped
111
+ "maxResponseMB": 100
96
112
  },
97
113
  "publisher": {
98
114
  "enabled": true,
@@ -271,7 +287,12 @@
271
287
  "ext-tracker": {
272
288
  "enabled": true,
273
289
  "database": "/var/fhir/data/extension-tracker.db",
274
- "url": "/ext-tracker"
290
+ "url": "/ext-tracker",
291
+ // the token submissions must carry (Authorization: Bearer ...). The IG Publisher sends
292
+ // 9D1C23C0-3915-4542-882E-2BCC55645DF8 - it's in the publisher's source, so it is not a
293
+ // secret, but it keeps out submissions that aren't from the publisher. Omit it and
294
+ // submissions need no token
295
+ "token": "9D1C23C0-3915-4542-882E-2BCC55645DF8"
275
296
  },
276
297
  // TestReports from TxTester and other tools, at /testing. See testing/readme.md
277
298
  "testing": {
@@ -4,9 +4,100 @@ const Database = require('better-sqlite3');
4
4
  const htmlServer = require('../library/html-server');
5
5
  const escape = require('escape-html');
6
6
  const packageJson = require('../package.json');
7
+ const { tokenMatches, tokenConfigured } = require('../library/request-token');
7
8
 
8
9
  const TEMPLATE_NAME = 'ext-tracker';
9
10
 
11
+ // What the IG Publisher submits (ExtensionTracker.generate() in the publisher): anything
12
+ // that doesn't look like that is refused, which keeps what anyone can put in the database
13
+ // (and so in these pages) to the shapes the pages expect
14
+ const CODE = /^[A-Za-z0-9._-]{1,100}$/;
15
+ const VERSION = /^[A-Za-z0-9._+-]{1,50}$/;
16
+ const FHIR_VERSION = /^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/;
17
+ const URL_LIKE = /^[A-Za-z][A-Za-z0-9+.-]*:[^\s"'<>\\]{1,1000}$/;
18
+ const RESOURCE_TYPE = /^[A-Za-z][A-Za-z0-9_]{0,99}$/;
19
+ // an element path, e.g. Patient.name or ImplementationGuide.definition.page (a logical
20
+ // model's type can be a URL)
21
+ const PATH = /^[A-Za-z][\w.:/#%[\]-]{0,511}$/;
22
+ const TYPE_CODE = /^[^\s"'<>\\]{1,256}$/;
23
+ const MAX_TITLE = 1024;
24
+ const MAX_ITEMS = 20000;
25
+
26
+ /**
27
+ * Checks that a submission looks like what the IG Publisher sends.
28
+ *
29
+ * @returns {string[]} what is wrong (empty if nothing)
30
+ */
31
+ function validateSubmission(data) {
32
+ const errors = [];
33
+ const check = (ok, message) => {
34
+ if (!ok && errors.length < 10) {
35
+ errors.push(message);
36
+ }
37
+ };
38
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
39
+ const str = (v, re) => typeof v === 'string' && re.test(v);
40
+ const optionalTitle = (v) => v === undefined || v === null || (typeof v === 'string' && v.length <= MAX_TITLE);
41
+ const onlyKeys = (obj, keys, where) => {
42
+ for (const k of Object.keys(obj)) {
43
+ check(keys.includes(k), `${where}: unexpected property ${JSON.stringify(k.substring(0, 50))}`);
44
+ }
45
+ };
46
+
47
+ if (!isObject(data)) {
48
+ return ['the submission must be a JSON object'];
49
+ }
50
+ onlyKeys(data, ['package', 'version', 'fhirVersion', 'jurisdiction', 'extensions', 'profiles', 'usage'], 'submission');
51
+ check(str(data.package, CODE), 'package must be a package id');
52
+ check(str(data.version, VERSION), 'version must be a version');
53
+ check(str(data.fhirVersion, FHIR_VERSION), 'fhirVersion must be a FHIR version (e.g. 4.0.1)');
54
+ check(data.jurisdiction === undefined || data.jurisdiction === null || str(data.jurisdiction, CODE), 'jurisdiction must be a code');
55
+
56
+ let items = 0;
57
+ if (data.extensions !== undefined) {
58
+ check(Array.isArray(data.extensions), 'extensions must be an array');
59
+ for (const [i, ext] of (Array.isArray(data.extensions) ? data.extensions : []).entries()) {
60
+ items++;
61
+ if (!isObject(ext)) {
62
+ check(false, `extensions[${i}] must be an object`);
63
+ continue;
64
+ }
65
+ onlyKeys(ext, ['url', 'title', 'types'], `extensions[${i}]`);
66
+ check(str(ext.url, URL_LIKE), `extensions[${i}].url must be a URL`);
67
+ check(optionalTitle(ext.title), `extensions[${i}].title must be text`);
68
+ check(ext.types === undefined || (Array.isArray(ext.types) && ext.types.every(t => str(t, TYPE_CODE))), `extensions[${i}].types must be an array of type codes`);
69
+ items += Array.isArray(ext.types) ? ext.types.length : 0;
70
+ }
71
+ }
72
+ if (data.profiles !== undefined) {
73
+ check(isObject(data.profiles), 'profiles must be an object');
74
+ for (const [type, list] of Object.entries(isObject(data.profiles) ? data.profiles : {})) {
75
+ check(RESOURCE_TYPE.test(type), `profiles: ${JSON.stringify(type.substring(0, 50))} is not a resource type`);
76
+ check(Array.isArray(list), `profiles.${type} must be an array`);
77
+ for (const [i, prof] of (Array.isArray(list) ? list : []).entries()) {
78
+ items++;
79
+ if (!isObject(prof)) {
80
+ check(false, `profiles.${type}[${i}] must be an object`);
81
+ continue;
82
+ }
83
+ onlyKeys(prof, ['url', 'title'], `profiles.${type}[${i}]`);
84
+ check(str(prof.url, URL_LIKE), `profiles.${type}[${i}].url must be a URL`);
85
+ check(optionalTitle(prof.title), `profiles.${type}[${i}].title must be text`);
86
+ }
87
+ }
88
+ }
89
+ if (data.usage !== undefined) {
90
+ check(isObject(data.usage), 'usage must be an object');
91
+ for (const [url, locations] of Object.entries(isObject(data.usage) ? data.usage : {})) {
92
+ check(URL_LIKE.test(url), `usage: ${JSON.stringify(url.substring(0, 50))} is not a URL`);
93
+ check(Array.isArray(locations) && locations.every(l => str(l, PATH)), `usage[${JSON.stringify(url.substring(0, 50))}] must be an array of element paths`);
94
+ items += Array.isArray(locations) ? locations.length : 0;
95
+ }
96
+ }
97
+ check(items <= MAX_ITEMS, `the submission has more than ${MAX_ITEMS} items`);
98
+ return errors;
99
+ }
100
+
10
101
  class ExtensionTrackerModule {
11
102
  constructor(stats) {
12
103
  this.stats = stats;
@@ -15,6 +106,7 @@ class ExtensionTrackerModule {
15
106
  }
16
107
 
17
108
  initialize(config, app) {
109
+ this.config = config;
18
110
  const dbPath = config.database || path.join(config.folder || '.', 'extension-tracker.db');
19
111
  this.db = new Database(dbPath);
20
112
  this.db.pragma('journal_mode = WAL');
@@ -130,10 +222,12 @@ class ExtensionTrackerModule {
130
222
  * @param {string} cookiePrefix - cookie name prefix for persistence
131
223
  */
132
224
  buildColumnFilters(tableId, columns, cookiePrefix) {
225
+ // goes inside a <script>, and the options are submitted data: with no '<' in it, no
226
+ // value can close the script element
133
227
  const colsJson = JSON.stringify(columns.map(c => ({
134
228
  type: c.type || 'text',
135
229
  options: c.options || []
136
- })));
230
+ }))).replace(/</g, '\\u003c');
137
231
 
138
232
  return `
139
233
  <script>
@@ -246,8 +340,21 @@ class ExtensionTrackerModule {
246
340
  const startTime = Date.now();
247
341
  const data = req.body;
248
342
 
249
- if (!data.package || !data.version || !data.fhirVersion) {
250
- return res.status(400).json({ error: 'Missing required fields: package, version, fhirVersion' });
343
+ // The IG Publisher sends a token. It is in the publisher's source, so it is no secret,
344
+ // but it keeps out anything that isn't trying to look like the publisher
345
+ if (tokenConfigured(this.config.token)) {
346
+ const auth = req.get('Authorization') || '';
347
+ const supplied = auth.replace(/^Bearer\s+/i, '');
348
+ if (!tokenMatches(this.config.token, supplied)) {
349
+ return res.status(401).json({ error: 'A valid token is required in the Authorization header' });
350
+ }
351
+ }
352
+
353
+ const errors = validateSubmission(data);
354
+ if (errors.length > 0) {
355
+ // logged, so that a real publisher submission refused by a rule that is too strict is seen
356
+ console.log(`Extension tracker: refused a submission for ${String(data?.package).substring(0, 100)} from ${req.ip}: ${errors.slice(0, 3).join('; ')}`);
357
+ return res.status(400).json({ error: 'Not a valid submission', issues: errors });
251
358
  }
252
359
 
253
360
  try {
@@ -695,3 +802,4 @@ class ExtensionTrackerModule {
695
802
  }
696
803
 
697
804
  module.exports = ExtensionTrackerModule;
805
+ module.exports.validateSubmission = validateSubmission;
@@ -14,11 +14,14 @@ Tracks extensions, profiles, and extension usage across FHIR Implementation Guid
14
14
 
15
15
  - **database**: path to the SQLite database file (auto-created if missing)
16
16
  - **url** (optional): URL base to mount at (defaults to `/ext-tracker`)
17
+ - **token** (optional): the token a submission must carry, as `Authorization: Bearer <token>`. The IG Publisher sends `9D1C23C0-3915-4542-882E-2BCC55645DF8`. It is in the publisher's source, so it is not a secret, but it keeps out submissions that aren't from the publisher. Without it, submissions need no token
17
18
 
18
19
  ## POST — Submit IG Data
19
20
 
20
21
  POST JSON to the module root. Required fields: `package`, `version`, `fhirVersion`. Optional: `jurisdiction`, `extensions`, `profiles`, `usage`.
21
22
 
23
+ A submission must look like what the IG Publisher sends, or it is refused (400): no other properties; `package`, `version`, `fhirVersion` and `jurisdiction` are codes; extension, profile and usage keys are URLs; `profiles` is keyed by resource type; usage locations are element paths; titles are text of at most 1024 characters; and no more than 20000 items in all.
24
+
22
25
  ```
23
26
  POST /ext-tracker
24
27
  Content-Type: application/json
@@ -70,32 +70,32 @@ class HtmlServer {
70
70
 
71
71
  // Perform template replacements
72
72
  let html = template
73
- .replace(/\[%title%\]/g, escape(title))
74
- .replace(/\[%content%\]/g, content) // Content is assumed to be already-safe HTML
73
+ .replace(/\[%title%\]/g, () => escape(title))
74
+ .replace(/\[%content%\]/g, () => content) // Content is assumed to be already-safe HTML
75
75
  // [%ver%] is the FHIRsmith version in every template (it follows the FHIRsmith link)
76
- .replace(/\[%ver%\]/g, escape(packageJson.version))
77
- .replace(/\[%download-date%\]/g, escape(renderOptions.downloadDate))
76
+ .replace(/\[%ver%\]/g, () => escape(packageJson.version))
77
+ .replace(/\[%download-date%\]/g, () => escape(renderOptions.downloadDate))
78
78
  // "last updated 35 minutes ago" / "not yet updated", for the crawler-driven modules
79
- .replace(/\[%crawler-status%\]/g, escape(renderOptions.crawlerStatus || 'not yet updated'))
80
- .replace(/\[%total-resources%\]/g, escape(renderOptions.totalResources.toLocaleString()))
81
- .replace(/\[%total-packages%\]/g, escape(renderOptions.totalPackages.toLocaleString()))
82
- .replace(/\[%endpoint-path%\]/g, escape(renderOptions.endpointpath))
83
- .replace(/\[%fhir-version%\]/g, escape(renderOptions.fhirversion))
84
- .replace(/\[%ms%\]/g, escape(renderOptions.processingTime.toString()))
85
- .replace(/\[%sponsorMessage%\]/g, sponsorMessage)
86
- .replace(/\[%about%\]/g, renderOptions.about || '')
79
+ .replace(/\[%crawler-status%\]/g, () => escape(renderOptions.crawlerStatus || 'not yet updated'))
80
+ .replace(/\[%total-resources%\]/g, () => escape(renderOptions.totalResources.toLocaleString()))
81
+ .replace(/\[%total-packages%\]/g, () => escape(renderOptions.totalPackages.toLocaleString()))
82
+ .replace(/\[%endpoint-path%\]/g, () => escape(renderOptions.endpointpath))
83
+ .replace(/\[%fhir-version%\]/g, () => escape(renderOptions.fhirversion))
84
+ .replace(/\[%ms%\]/g, () => escape(renderOptions.processingTime.toString()))
85
+ .replace(/\[%sponsorMessage%\]/g, () => sponsorMessage)
86
+ .replace(/\[%about%\]/g, () => renderOptions.about || '')
87
87
  // raw HTML, like [%about%]: the caller decides whether the nav item exists at all
88
- .replace(/\[%library-link%\]/g, renderOptions.libraryLink || '')
88
+ .replace(/\[%library-link%\]/g, () => renderOptions.libraryLink || '')
89
89
  // raw HTML too: the OpenAPI description, where a template's module has one in some places only
90
- .replace(/\[%api-link%\]/g, renderOptions.apiLink || '')
91
- .replace(/\[%api-head%\]/g, renderOptions.apiHead || '');
90
+ .replace(/\[%api-link%\]/g, () => renderOptions.apiLink || '')
91
+ .replace(/\[%api-head%\]/g, () => renderOptions.apiHead || '');
92
92
 
93
93
  // Handle any custom template variables
94
94
  if (options.templateVars) {
95
95
  for (const [key, value] of Object.entries(options.templateVars)) {
96
96
  const placeholder = `[%${key}%]`;
97
97
  const escapedValue = typeof value === 'string' ? escape(value) : String(value);
98
- html = html.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), escapedValue);
98
+ html = html.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), () => escapedValue);
99
99
  }
100
100
  }
101
101
 
package/library/html.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const {validateParameter, validateOptionalParameter} = require("./utilities");
2
2
  const escape = require('escape-html');
3
3
 
4
+ // Attributes that take a URL, and the schemes that make a URL run script. Browsers ignore
5
+ // leading whitespace and control characters, and embedded tabs and newlines, in the scheme.
6
+ const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'xlink:href']);
7
+ // eslint-disable-next-line no-control-regex -- control characters are exactly what browsers skip
8
+ const UNSAFE_URL = /^[\s\x00-\x1f]*(j[\s]*a[\s]*v[\s]*a[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|v[\s]*b[\s]*s[\s]*c[\s]*r[\s]*i[\s]*p[\s]*t|d[\s]*a[\s]*t[\s]*a)[\s]*:/i;
9
+
4
10
  const NodeType = {
5
11
  Document: 'Document',
6
12
  Element: 'Element',
@@ -25,6 +31,9 @@ class XhtmlNode {
25
31
  // Attribute methods
26
32
  setAttribute(name, value) {
27
33
  if (value != null) {
34
+ if (URL_ATTRIBUTES.has(String(name).toLowerCase()) && UNSAFE_URL.test(String(value))) {
35
+ value = '#'; // a link that runs script (javascript:, data:, vbscript:) - from resource content
36
+ }
28
37
  this.attributes.set(name, value);
29
38
  }
30
39
  return this;
@@ -0,0 +1,203 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * Checks FHIR narrative (and any other XHTML that will be shown in a page) for active
9
+ * content: anything that can run script, submit or navigate on its own, load another
10
+ * document, or restyle the page around it.
11
+ *
12
+ * FHIR's rule is that narrative "SHALL contain only the basic HTML formatting elements ...,
13
+ * <a> elements (either name or href), images and internally contained style attributes",
14
+ * and "SHALL NOT contain a head, a body element, external stylesheet references, deprecated
15
+ * elements, scripts, forms, base/link/xlink, frames, iframes, objects or event related
16
+ * attributes". This checks the active-content part of that, not the whole rule.
17
+ *
18
+ * The XHTML is parsed the way a browser parses it when it is inserted into a page (parse5
19
+ * implements the HTML parsing algorithm), so entity-encoded, mixed-case or malformed markup
20
+ * is checked as the browser would see it, not as it is written.
21
+ */
22
+
23
+ const parse5 = require('parse5');
24
+
25
+ // Elements that run script, load or embed another document, submit data, or change how
26
+ // the page around them is read. svg and math are here because FHIR narrative has no
27
+ // inline graphics (images are <img>), and both can carry script.
28
+ const ACTIVE_ELEMENTS = new Set([
29
+ 'script', 'style', 'link', 'meta', 'base',
30
+ 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'portal', 'template',
31
+ 'form', 'input', 'button', 'select', 'textarea', 'isindex', 'keygen',
32
+ 'svg', 'math', 'head', 'body', 'html'
33
+ ]);
34
+
35
+ // Attributes that hold a URL
36
+ const URL_ATTRIBUTES = new Set([
37
+ 'href', 'src', 'action', 'formaction', 'xlink:href', 'background', 'poster', 'srcset',
38
+ 'data', 'codebase', 'cite', 'longdesc', 'usemap', 'dynsrc', 'lowsrc', 'ping', 'manifest'
39
+ ]);
40
+
41
+ // Attributes that are active whatever their value
42
+ const ACTIVE_ATTRIBUTES = new Set(['srcdoc', 'http-equiv', 'formaction']);
43
+
44
+ // CSS in a style attribute that runs script or binds behaviour (old IE and Gecko)
45
+ const ACTIVE_CSS = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import/;
46
+
47
+ // The scheme of a URL, as a browser reads it: leading spaces and control characters
48
+ // are ignored, and so are tabs and newlines anywhere
49
+ function urlScheme(url) {
50
+ // eslint-disable-next-line no-control-regex -- control characters are what browsers strip
51
+ const cleaned = String(url).replace(/^[\x00-\x20]+/, '').replace(/[\t\n\r]/g, '');
52
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
53
+ return m ? { scheme: m[1].toLowerCase(), rest: cleaned.substring(m[0].length) } : null;
54
+ }
55
+
56
+ // Every URL in an attribute value (srcset has several)
57
+ function urlsIn(name, value) {
58
+ if (name === 'srcset') {
59
+ return String(value).split(',').map(s => s.trim().split(/\s+/)[0]).filter(Boolean);
60
+ }
61
+ return [value];
62
+ }
63
+
64
+ // CSS with comments removed and escapes decoded, so \65 xpression( is seen as expression(
65
+ function normaliseCss(css) {
66
+ return String(css)
67
+ .replace(/\/\*[\s\S]*?\*\//g, '')
68
+ .replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => {
69
+ const cp = parseInt(hex, 16);
70
+ return cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : '';
71
+ })
72
+ .replace(/\\(.)/g, '$1')
73
+ .replace(/\s+/g, '')
74
+ .toLowerCase();
75
+ }
76
+
77
+ function describe(node) {
78
+ return `<${node.tagName}>`;
79
+ }
80
+
81
+ function checkAttribute(node, attr, problems) {
82
+ const name = (attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name).toLowerCase();
83
+ const value = attr.value || '';
84
+ if (name.startsWith('on')) {
85
+ problems.push(`an event handler attribute (${name}) on ${describe(node)}`);
86
+ return;
87
+ }
88
+ if (ACTIVE_ATTRIBUTES.has(name)) {
89
+ problems.push(`a ${name} attribute on ${describe(node)}`);
90
+ return;
91
+ }
92
+ if (name === 'style') {
93
+ if (ACTIVE_CSS.test(normaliseCss(value))) {
94
+ problems.push(`script or behaviour in the style attribute of ${describe(node)}`);
95
+ }
96
+ return;
97
+ }
98
+ if (URL_ATTRIBUTES.has(name)) {
99
+ for (const url of urlsIn(name, value)) {
100
+ const s = urlScheme(url);
101
+ if (!s) {
102
+ continue;
103
+ }
104
+ if (s.scheme === 'javascript' || s.scheme === 'vbscript' || s.scheme === 'livescript') {
105
+ problems.push(`a ${s.scheme}: URL in the ${name} of ${describe(node)}`);
106
+ } else if (s.scheme === 'data') {
107
+ // an inline image is fine: an image can't run script
108
+ const isImage = node.tagName === 'img' && (name === 'src' || name === 'srcset')
109
+ && /^image\//i.test(s.rest.trim());
110
+ if (!isImage) {
111
+ problems.push(`a data: URL in the ${name} of ${describe(node)}`);
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ function walk(node, problems) {
119
+ if (node.tagName) {
120
+ if (ACTIVE_ELEMENTS.has(node.tagName.toLowerCase())) {
121
+ problems.push(`a ${describe(node)} element`);
122
+ }
123
+ for (const attr of node.attrs || []) {
124
+ checkAttribute(node, attr, problems);
125
+ }
126
+ }
127
+ // a <template>'s content is a separate fragment
128
+ for (const child of [...(node.childNodes || []), ...(node.content ? [node.content] : [])]) {
129
+ walk(child, problems);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Checks XHTML for active content.
135
+ *
136
+ * @param {string} xhtml - e.g. a narrative's text.div
137
+ * @returns {string|null} what was found (the first few things, if several), or null if
138
+ * there is no active content
139
+ */
140
+ function checkNarrative(xhtml) {
141
+ if (xhtml == null || xhtml === '') {
142
+ return null;
143
+ }
144
+ if (typeof xhtml !== 'string') {
145
+ return 'the narrative is not a string';
146
+ }
147
+ const problems = [];
148
+ walk(parse5.parseFragment(xhtml), problems);
149
+ if (problems.length === 0) {
150
+ return null;
151
+ }
152
+ const shown = [...new Set(problems)];
153
+ const more = shown.length > 3 ? ` (and ${shown.length - 3} more)` : '';
154
+ return `active content in the narrative: ${shown.slice(0, 3).join('; ')}${more}`;
155
+ }
156
+
157
+ /**
158
+ * Checks every narrative in a resource: its own, its contained resources', and those of
159
+ * the resources in a Bundle's entries.
160
+ *
161
+ * @param {object} resource - the resource as JSON
162
+ * @returns {string|null} what was found, with where, or null
163
+ */
164
+ function checkResourceNarratives(resource) {
165
+ const check = (res, where) => {
166
+ if (!res || typeof res !== 'object') {
167
+ return null;
168
+ }
169
+ const found = checkNarrative(res.text?.div);
170
+ if (found) {
171
+ return `${where}: ${found}`;
172
+ }
173
+ for (const [i, c] of (Array.isArray(res.contained) ? res.contained : []).entries()) {
174
+ const r = check(c, `${where}.contained[${i}]${c?.id ? ` (#${c.id})` : ''}`);
175
+ if (r) {
176
+ return r;
177
+ }
178
+ }
179
+ for (const [i, e] of (Array.isArray(res.entry) ? res.entry : []).entries()) {
180
+ const r = check(e?.resource, `${where}.entry[${i}]`);
181
+ if (r) {
182
+ return r;
183
+ }
184
+ }
185
+ return null;
186
+ };
187
+ return check(resource, resource?.resourceType ? `${resource.resourceType}${resource.id ? '/' + resource.id : ''}` : 'resource');
188
+ }
189
+
190
+ /**
191
+ * As checkResourceNarratives, but throws.
192
+ *
193
+ * @param {object} resource
194
+ * @param {string} [context] - for the message, e.g. the package it came from
195
+ */
196
+ function assertNoActiveContent(resource, context) {
197
+ const found = checkResourceNarratives(resource);
198
+ if (found) {
199
+ throw new Error(context ? `${context}: ${found}` : found);
200
+ }
201
+ }
202
+
203
+ module.exports = { checkNarrative, checkResourceNarratives, assertNoActiveContent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "txVersion": "1.9.6",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
@@ -56,6 +56,7 @@
56
56
  "natural": "^8.1.1",
57
57
  "node-cron": "^4.6.0",
58
58
  "pako": "^2.1.0",
59
+ "parse5": "^7.3.0",
59
60
  "passport": "^0.7.0",
60
61
  "passport-facebook": "^3.0.0",
61
62
  "passport-github2": "^0.1.12",