From 67fb44c7b7d7b3d31970cd85ba2aa990f2d8e8ff Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 29 Sep 2026 08:10:59 +1000 Subject: [PATCH 1/3] fix snomed version handling --- library/languages.js | 15 +++++++++ registry/registry.js | 4 ++- tx/cs/cs-snomed.js | 75 ++------------------------------------------ tx/sct/editions.js | 70 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 73 deletions(-) create mode 100644 tx/sct/editions.js diff --git a/library/languages.js b/library/languages.js index 9fbfcf12..67fcc255 100644 --- a/library/languages.js +++ b/library/languages.js @@ -562,6 +562,21 @@ class Languages { } return parts.join(', '); } + + /** + * The languages as they were asked for, in the form a displayLanguage parameter takes + * (the Accept-Language syntax: "en,it;q=0.8,*"). Unlike asString, which is for reading + * in messages, there are no spaces; unlike toString, implicit languages are left out. + */ + asParameter() { + return this.languages.filter(lang => !lang.implicit).map(lang => { + if (lang.quality === undefined || lang.quality === 1.0) { + return lang.code; + } else { + return `${lang.code};q=${lang.quality}`; + } + }).join(','); + } } /** diff --git a/registry/registry.js b/registry/registry.js index 2679b446..998be128 100644 --- a/registry/registry.js +++ b/registry/registry.js @@ -721,7 +721,8 @@ class RegistryModule { * @returns {string} Formatted URL with edition description */ _describeSnomedEdition(url) { - if (!url.startsWith('http://snomed.info/sct')) { + // the test edition is published under xsct (http://snomed.info/xsct/31000003106/...) + if (!url.startsWith('http://snomed.info/sct') && !url.startsWith('http://snomed.info/xsct')) { return url; } @@ -745,6 +746,7 @@ class RegistryModule { // Match edition code to description switch (editionCode) { case '900000000000207008': edition = 'Intl'; break; + case '31000003106': edition = 'Test'; break; // tx-ecosystem test edition (xsct) case '731000124108': edition = 'US'; break; case '32506021000036107': edition = 'AU'; break; case '449081005': edition = 'ES/Intl'; break; diff --git a/tx/cs/cs-snomed.js b/tx/cs/cs-snomed.js index a20bde7d..55b8f480 100644 --- a/tx/cs/cs-snomed.js +++ b/tx/cs/cs-snomed.js @@ -16,6 +16,7 @@ const {ConceptMap} = require("../library/conceptmap"); const {ECLLexer, ECLParser, ECLNodeType, ECLTokenType} = require("../sct/ecl"); const {Issue} = require("../library/operation-outcome"); const {debugLog} = require("../operation-context"); +const {editionName, editionCode} = require("../sct/editions"); const {SnomedTextIndex, rankMatches, filterTokens} = require("../sct/text-index"); const Logger = require("../../library/logger"); @@ -3038,81 +3039,11 @@ class SnomedServicesFactory extends CodeSystemFactoryProvider { } function getEditionName(edition) { - const editionMap = { - '900000000000207008': 'International Edition', - // The terminology-ecosystem test distribution, published under xsct - see the - // tx-ecosystem IG, which requires a server claiming the snomed mode to load it. - '31000003106': 'Test Edition', - '449081005': 'International Spanish Edition', - '11000221109': 'Argentinian Edition', - '32506021000036107': 'Australian Edition (with drug extension)', - '11000234105': 'Austrian Edition', - '11000172109': 'Belgian Edition', - '20621000087109': 'Canadian English Edition', - '20611000087101': 'Canadian Canadian French Edition', - '554471000005108': 'Danish Edition', - '11000279109': 'Czech Edition', - '11000181102': 'Estonian Edition', - '11000229106': 'Finnish Edition', - '11000274103': 'German Edition', - '1121000189102': 'Indian Edition', - '827022005': 'IPS Terminology', - '11000220105': 'Irish Edition', - '11000146104': 'Netherlands Edition', - '21000210109': 'New Zealand Edition', - '51000202101': 'Norwegian Edition', - '11000267109': 'Republic of Korea Edition (South Korea)', - '900000001000122104': 'Spanish National Edition', - '45991000052106': 'Swedish Edition', - '2011000195101': 'Swiss Edition', - '83821000000107': 'UK Edition', - '999000021000000109': 'UK Clinical Edition', - '5631000179106': 'Uruguay Edition', - '731000124108': 'US Edition', - '21000325107': 'Chilean Edition', - '5991000124107': 'US Edition (with ICD-10-CM maps)' - }; - - return editionMap[edition] || 'Unknown Edition'; + return editionName(edition) || 'Unknown Edition'; } function getEditionCode(edition) { - const editionMap = { - '900000000000207008': 'Intl', - // The terminology-ecosystem test distribution, published under xsct - see the - // tx-ecosystem IG, which requires a server claiming the snomed mode to load it. - '31000003106': 'Test', - '449081005': 'es', - '11000221109': 'AR-es', - '32506021000036107': 'AU+', - '11000234105': 'AT', - '11000172109': 'BE', - '20621000087109': 'CA-en', - '20611000087101': 'CA-fr', - '554471000005108': 'DK', - '11000279109': 'CZ', - '11000181102': 'ES', - '11000229106': 'FI', - '11000274103': 'DE', - '1121000189102': 'IN', - '827022005': 'IPS', - '11000220105': 'IE', - '11000146104': 'NL', - '21000210109': 'NZ', - '51000202101': 'NO', - '11000267109': 'KR', - '900000001000122104': 'ES-es', - '45991000052106': 'SW', - '2011000195101': 'CH', - '83821000000107': 'UK', - '999000021000000109': 'UK-Clinical', - '5631000179106': 'UR', - '731000124108': 'US', - '21000325107': 'CL', - '5991000124107': 'US+)' - }; - - return editionMap[edition] || 'Unknown Edition'; + return editionCode(edition) || 'Unknown Edition'; } diff --git a/tx/sct/editions.js b/tx/sct/editions.js new file mode 100644 index 00000000..0e71193f --- /dev/null +++ b/tx/sct/editions.js @@ -0,0 +1,70 @@ +/** + * The SNOMED CT editions FHIRsmith knows by name - the one table, used for the provider's + * description and name (cs-snomed.js) and for the edition named in unknown-code messages + * (SCTVersion in workers/worker.js). Keyed by the edition's module id, as it appears in a + * version URI: http://snomed.info/sct/[module]/version/[date] + * + * name - human readable edition name + * code - short code used in the provider name ("SCT US") + * + * (The importer and the registry keep their own tables: those carry import settings and + * registry codes, not display names.) + */ +const EDITIONS = { + '900000000000207008': { name: 'International Edition', code: 'Intl' }, + // The terminology-ecosystem test distribution, published under xsct - see the + // tx-ecosystem IG, which requires a server claiming the snomed mode to load it. + '31000003106': { name: 'Test Edition', code: 'Test' }, + '449081005': { name: 'International Spanish Edition', code: 'es' }, + '11000221109': { name: 'Argentinian Edition', code: 'AR-es' }, + '32506021000036107': { name: 'Australian Edition (with drug extension)', code: 'AU+' }, + '11000234105': { name: 'Austrian Edition', code: 'AT' }, + '11000172109': { name: 'Belgian Edition', code: 'BE' }, + '20621000087109': { name: 'Canadian English Edition', code: 'CA-en' }, + '20611000087101': { name: 'Canadian Canadian French Edition', code: 'CA-fr' }, + '554471000005108': { name: 'Danish Edition', code: 'DK' }, + '11000279109': { name: 'Czech Edition', code: 'CZ' }, + '11000181102': { name: 'Estonian Edition', code: 'ET' }, + '11000229106': { name: 'Finnish Edition', code: 'FI' }, + '11000274103': { name: 'German Edition', code: 'DE' }, + '1121000189102': { name: 'Indian Edition', code: 'IN' }, + '827022005': { name: 'IPS Terminology', code: 'IPS' }, + '11000220105': { name: 'Irish Edition', code: 'IE' }, + '11000146104': { name: 'Netherlands Edition', code: 'NL' }, + '21000210109': { name: 'New Zealand Edition', code: 'NZ' }, + '51000202101': { name: 'Norwegian Edition', code: 'NO' }, + '11000267109': { name: 'Republic of Korea Edition (South Korea)', code: 'KR' }, + '900000001000122104': { name: 'Spanish National Edition', code: 'ES-es' }, + '45991000052106': { name: 'Swedish Edition', code: 'SE' }, + '2011000195101': { name: 'Swiss Edition', code: 'CH' }, + '83821000000107': { name: 'UK Edition', code: 'UK' }, + '999000021000000109': { name: 'UK Clinical Edition', code: 'UK-Clinical' }, + '5631000179106': { name: 'Uruguay Edition', code: 'UY' }, + '731000124108': { name: 'US Edition', code: 'US' }, + '21000325107': { name: 'Chilean Edition', code: 'CL' }, + '5991000124107': { name: 'US Edition (with ICD-10-CM maps)', code: 'US+' } +}; + +/** + * @param {string} edition - edition module id + * @returns {string|null} the edition's name, or null if it isn't a known edition + */ +function editionName(edition) { + const e = EDITIONS[edition]; + return e ? e.name : null; +} + +/** + * @param {string} edition - edition module id + * @returns {string|null} the edition's short code, or null if it isn't a known edition + */ +function editionCode(edition) { + const e = EDITIONS[edition]; + return e ? e.code : null; +} + +module.exports = { + EDITIONS, + editionName, + editionCode +}; From 321a954bed16cea3fff539d8f74091bee6f16bce Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 29 Sep 2026 08:11:17 +1000 Subject: [PATCH 2/3] fix displayLanguage whitespace handling --- tx/workers/expand.js | 4 ++-- tx/workers/worker.js | 38 +++----------------------------------- 2 files changed, 5 insertions(+), 37 deletions(-) diff --git a/tx/workers/expand.js b/tx/workers/expand.js index 6e850003..d9e09f83 100644 --- a/tx/workers/expand.js +++ b/tx/workers/expand.js @@ -1385,9 +1385,9 @@ class ValueSetExpander { } if (this.params.DisplayLanguages) { - this.addParamCode(exp, 'displayLanguage', this.params.DisplayLanguages.asString(true)); + this.addParamCode(exp, 'displayLanguage', this.params.DisplayLanguages.asParameter()); } else if (this.params.HTTPLanguages) { - this.addParamCode(exp, 'displayLanguage', this.params.HTTPLanguages.asString(true)); + this.addParamCode(exp, 'displayLanguage', this.params.HTTPLanguages.asParameter()); } if (this.params.designations) { for (const s of this.params.designations) { diff --git a/tx/workers/worker.js b/tx/workers/worker.js index 851259ec..52925293 100644 --- a/tx/workers/worker.js +++ b/tx/workers/worker.js @@ -7,6 +7,7 @@ const {Issue} = require("../library/operation-outcome"); const {Languages} = require("../../library/languages"); const {ConceptMap} = require("../library/conceptmap"); const {Renderer} = require("../library/renderer"); +const {editionName} = require("../sct/editions"); // The cache-id travels as an HTTP header (not an operation parameter) so proxies / // load-balancers can act on it and the server can reject it before parsing the body. @@ -1100,42 +1101,9 @@ function Unknown_Code_in_VersionSCT(url, version) { function SCTVersion(url, ver) { if (url !== 'http://snomed.info/sct' || !ver) { return ''; - } else { - let result = 'unknown'; - let s = ver.split('/'); - if (s.length >= 5) { - if (s[4] === '900000000000207008') result = 'International Edition'; - else if (s[4] === '449081005') result = 'International Spanish Edition'; - else if (s[4] === '11000221109') result = 'Argentinian Edition'; - else if (s[4] === '32506021000036107') result = 'Australian Edition (with drug extension)'; - else if (s[4] === '11000234105') result = 'Austrian Edition'; - else if (s[4] === '11000172109') result = 'Belgian Edition'; - else if (s[4] === '20621000087109') result = 'Canadian English Edition'; - else if (s[4] === '20611000087101') result = 'Canadian Canadian French Edition'; - else if (s[4] === '11000279109') result = 'Czech Edition'; - else if (s[4] === '554471000005108') result = 'Danish Edition'; - else if (s[4] === '11000181102') result = 'Estonian Edition'; - else if (s[4] === '11000229106') result = 'Finnish Edition'; - else if (s[4] === '11000274103') result = 'German Edition'; - else if (s[4] === '1121000189102') result = 'Indian Edition'; - else if (s[4] === '827022005') result = 'IPS Terminology'; - else if (s[4] === '11000220105') result = 'Irish Edition'; - else if (s[4] === '11000146104') result = 'Netherlands Edition'; - else if (s[4] === '21000210109') result = 'New Zealand Edition'; - else if (s[4] === '51000202101') result = 'Norwegian Edition'; - else if (s[4] === '11000267109') result = 'Republic of Korea Edition (South Korea)'; - else if (s[4] === '900000001000122104') result = 'Spanish National Edition'; - else if (s[4] === '45991000052106') result = 'Swedish Edition'; - else if (s[4] === '2011000195101') result = 'Swiss Edition'; - else if (s[4] === '83821000000107') result = 'UK Edition'; - else if (s[4] === '999000021000000109') result = 'UK Clinical Edition'; - else if (s[4] === '5631000179106') result = 'Uruguay Edition'; - else if (s[4] === '21000325107') result = 'Chilean Edition'; - else if (s[4] === '731000124108') result = 'US Edition'; - else if (s[4] === '5991000124107') result = 'US Edition (with ICD-10-CM maps)'; - } - return result; } + const s = ver.split('/'); + return (s.length >= 5 && editionName(s[4])) || 'unknown'; } module.exports = { From 00ebbb8160bac56d8557c61d690b854046112870 Mon Sep 17 00:00:00 2001 From: Grahame Grieve Date: Tue, 29 Sep 2026 08:11:28 +1000 Subject: [PATCH 3/3] tighten up express proxy support --- nginx.md | 24 ++++++++++++++++-------- server.js | 7 ++++++- tests/testing/testing.test.js | 2 +- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/nginx.md b/nginx.md index 2db6c49b..c242552f 100644 --- a/nginx.md +++ b/nginx.md @@ -13,9 +13,9 @@ public servers (tx.fhir.org, packages.fhir.org and so on) use. ### Headers to pass to the server -FHIRsmith runs Express with `trust proxy` enabled (`server.trustProxy` in config.json, -default `true`). That means it takes the client address, scheme and host from the -`X-Forwarded-*` headers, so nginx has to set them: +FHIRsmith runs Express with `trust proxy` enabled (`server.trustProxy` in config.json; by +default it trusts proxies on the same host or a private network). That means it takes the +client address, scheme and host from the `X-Forwarded-*` headers, so nginx has to set them: | Header | nginx value | What FHIRsmith uses it for | |---|---|---| @@ -49,11 +49,19 @@ them: ### Trusting the proxy -With `trustProxy: true`, anyone who can reach the FHIRsmith port directly can forge -`X-Forwarded-For`. FHIRsmith listens on all interfaces, so firewall the port so that only -nginx can reach it. You can also set `server.trustProxy` to `"loopback"`, or to the proxy's -address; any value that Express's `trust proxy` setting accepts will work. If you run -FHIRsmith without a proxy, set it to `false`. +By default FHIRsmith believes the `X-Forwarded-*` headers only when the request comes from +a loopback, link-local or private address (`"loopback, linklocal, uniquelocal"`) - nginx on +the same machine, or on the same docker or private network. A request straight from the +internet can't set its own client address, so it can't get round the rate limits on logins, +report submission and the publisher's task actions. + +If your proxy is somewhere else (a cloud load balancer with a public address, say), set +`server.trustProxy` to its address, or to the number of proxy hops; any value Express's +`trust proxy` setting accepts will work. If you run FHIRsmith without a proxy, set it to +`false`. Avoid `true`: it believes any `X-Forwarded-For`, so anyone who can reach the +FHIRsmith port directly can name their own IP address, and express-rate-limit logs +`ERR_ERL_PERMISSIVE_TRUST_PROXY` to say so. Either way, firewalling the port so that only +nginx can reach it is still a good idea. ### Upstream connections diff --git a/server.js b/server.js index 456aef61..98b9c511 100644 --- a/server.js +++ b/server.js @@ -79,7 +79,12 @@ const app = express(); // Behind nginx (or any reverse proxy): honor X-Forwarded-* so req.protocol and // req.hostname reflect what the client actually requested, not the loopback hop. -app.set('trust proxy', config.server.trustProxy ?? true); +// Only proxies on the same host or a private network are trusted by default. `true` would +// trust every X-Forwarded-For hop, so anyone who could reach the port directly could name +// their own IP address and walk around every rate limit (express-rate-limit refuses to be +// quiet about it: ERR_ERL_PERMISSIVE_TRUST_PROXY). See nginx.md. +const DEFAULT_TRUST_PROXY = 'loopback, linklocal, uniquelocal'; +app.set('trust proxy', config.server.trustProxy ?? DEFAULT_TRUST_PROXY); const PORT = process.env.PORT || config.server.port || 3000; diff --git a/tests/testing/testing.test.js b/tests/testing/testing.test.js index a71e0bdd..65951fbd 100644 --- a/tests/testing/testing.test.js +++ b/tests/testing/testing.test.js @@ -35,7 +35,7 @@ function report(overrides = {}) { // mirrors the body parsers server.js puts in front of every module async function makeApp(config = {}) { const app = express(); - app.set('trust proxy', true); + app.set('trust proxy', 'loopback'); app.use(express.raw({ type: 'application/fhir+json', limit: '50mb' })); app.use(express.raw({ type: 'application/fhir+xml', limit: '50mb' })); app.use(express.json({ limit: '50mb' }));