fhirsmith 0.16.0 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +40 -19
  3. package/config-template.json +25 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/library/html-server.js +16 -16
  7. package/library/html.js +9 -0
  8. package/library/narrative-check.js +203 -0
  9. package/library/npm-audit.js +42 -3
  10. package/package.json +2 -1
  11. package/packages/openapi.yaml +6 -3
  12. package/packages/package-crawler.js +189 -82
  13. package/packages/packages.js +33 -69
  14. package/packages/readme.md +1 -1
  15. package/packages/utilities/dedupe-versions.js +22 -8
  16. package/registry/crawler.js +8 -1
  17. package/registry/registry.js +18 -11
  18. package/release.sh +80 -0
  19. package/shl/shl.js +5 -3
  20. package/testing/readme.md +1 -1
  21. package/tx/cm/cm-database.js +62 -0
  22. package/tx/cm/cm-package.js +20 -3
  23. package/tx/cs/cs-cs.js +86 -18
  24. package/tx/cs/cs-loinc.js +32 -14
  25. package/tx/library/resource-ids.js +16 -1
  26. package/tx/library.js +9 -2
  27. package/tx/ocl/README.md +62 -6
  28. package/tx/ocl/cm-ocl.cjs +166 -44
  29. package/tx/ocl/cs-ocl.cjs +163 -13
  30. package/tx/ocl/resolve/public-access.js +143 -0
  31. package/tx/ocl/resolve/reference-resolver.js +458 -0
  32. package/tx/ocl/vs-ocl.cjs +167 -4
  33. package/tx/problems.js +7 -5
  34. package/tx/provider.js +5 -1
  35. package/tx/tx-html.js +46 -7
  36. package/tx/tx.js +22 -6
  37. package/tx/vs/vs-database.js +26 -0
  38. package/tx/vs/vs-package.js +42 -10
  39. package/tx/workers/expand.js +1 -1
  40. package/tx/workers/metadata.js +6 -1
  41. package/tx/workers/read.js +87 -5
  42. package/utilities/submit-test-report.js +1 -1
  43. package/xig/xig.js +88 -12
package/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ 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
+
55
+ ## [0.16.1] - 2026-10-16
56
+
57
+ ### Added
58
+
59
+ - The dashboard always says which FHIRsmith release the server runs, and whether a newer one is available (looked up once a day with the npm audit check; before, the latest release was only looked up, and only mentioned, when the audit found something)
60
+ - README: how to follow releases (GitHub release emails, the releases Atom feed, and the dashboard)
61
+
62
+ ### Fixed
63
+
64
+ - Packages: SUSHI (and anything else using fhir-package-loader) couldn't download packages from this server: `GET /packages/{id}/{version}` returned the version's JSON document instead of the tarball (since 0.16.0). The rule was "JSON when Accept includes `application/json`", but fhir-package-loader downloads with axios's default `Accept: application/json, text/plain, */*`, and plenty of other HTTP clients send JSON alongside `*/*` by default without wanting it. The version document is now returned only when the request prefers JSON: a JSON type ranked above any `*/*`, `application/*` or tarball type by q-value (`application/json` alone, or `application/json, */*;q=0.1`). Everything else gets the tarball, and `FHIR-Package-Download` still forces it
65
+ - A POST with no Content-Type now says so (it gave "Unsupported Media Type: " with nothing after it)
66
+ - A form post whose Content-Type has a charset (`application/x-www-form-urlencoded; charset=UTF-8`) was refused as an unsupported media type
67
+ - Links in published IGs to a resource by the id it had in its source (`ValueSet/2.16.840.1.113762.1.4.1`, from before ids were prefixed, as in `ValueSet/vsac-2.16.840.1.113762.1.4.1`) work again in a browser: a read of an id nothing has redirects to the one resource that had that id in its source, or lists them when there are several. Other requests still get a 404
68
+
69
+ ### Tx Conformance Statement
70
+
71
+ FHIRsmith v0.16.1 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)
72
+
8
73
  ## [0.16.0] - 2026-10-15
9
74
 
10
75
  ### Security
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:
@@ -259,7 +268,7 @@ Each GitHub Release includes:
259
268
  - `ghcr.io/healthintersections/fhirsmith:latest`
260
269
  - `ghcr.io/healthintersections/fhirsmith:vX.Y.Z`
261
270
  - `ghcr.io/healthintersections/fhirsmith:X.Y.Z`
262
- - **npm package** published to npmjs.org as `fhirsmith` *(if you add this)*
271
+ - **npm package** published to npmjs.org as [`fhirsmith`](https://www.npmjs.com/package/fhirsmith)
263
272
 
264
273
  Release images carry OCI labels, so an image says which release it is without
265
274
  having to trust the tag it was pulled under:
@@ -271,6 +280,19 @@ docker inspect --format '{{json .Config.Labels}}' ghcr.io/healthintersections/fh
271
280
  `org.opencontainers.image.version` is the release, `.revision` the commit it was
272
281
  built from, and `.source` is what links the package to this repository on GHCR.
273
282
 
283
+ ### Tracking Releases
284
+
285
+ Server administrators should follow releases: a server is only as safe as the
286
+ release it runs.
287
+
288
+ - **Email**: on the [GitHub repository](https://github.com/HealthIntersections/FHIRsmith),
289
+ choose **Watch → Custom → Releases**. Each release is emailed with its notes (needs a GitHub account)
290
+ - **Atom feed**: `https://github.com/HealthIntersections/FHIRsmith/releases.atom` - each
291
+ entry carries the release notes, for a feed reader or a Slack/Teams feed integration
292
+ - **The server itself**: once a day the server looks up the latest release on npm, and the
293
+ dashboard says whether a newer one is available (this is part of the npm audit check, so
294
+ `npmAudit.enabled = false` turns it off too)
295
+
274
296
  ### CI Images
275
297
 
276
298
  Every push to main also publishes `:cibuild` and `:cibuild-<sha>` to the same
@@ -326,30 +348,29 @@ GitHub Actions will automatically:
326
348
 
327
349
  or do it via a PR
328
350
 
329
- 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:
330
352
  ```bash
331
- git tag vX.Y.Z
332
- git push origin vX.Y.Z
353
+ ./release.sh X.Y.Z
333
354
  ```
334
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
+
335
368
  5. Monitor the release:
336
369
  - Check [GitHub Actions](https://github.com/HealthIntersections/fhirsmith/actions) for the Release workflow
337
370
  - Verify the [GitHub Release](https://github.com/HealthIntersections/fhirsmith/releases) was created
338
371
  - Confirm Docker images are available at [GHCR](https://github.com/HealthIntersections/fhirsmith/pkgs/container/fhirsmith)
339
372
 
340
- 6. Submit the tx conformance report to [testing.fhir.org](https://testing.fhir.org/testing):
341
- ```bash
342
- node utilities/submit-test-report.js -token {token}
343
- ```
344
-
345
- The tx test run writes `test-cases-report.json` next to `test-cases-summary.txt` (both
346
- are ignored by git); this uploads it as a TestReport. The report records the version in
347
- `package.json` when the tests ran, so for it to name the release, run the tx tests
348
- after step 2 - the script warns if the report is from a different version, or from a
349
- snapshot. Use `-server {url}` to send it to a different /testing server; the token can
350
- also come from `FHIRSMITH_TESTING_TOKEN`.
351
-
352
- 7. Update `package.json` to have the next release version -SNAPSHOT
373
+ 6. Update `package.json` to have the next release version -SNAPSHOT
353
374
 
354
375
  **If a release fails:**
355
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;