fhirsmith 0.12.0 → 0.13.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +323 -4
  2. package/README.md +1 -1
  3. package/config-template.json +13 -0
  4. package/library/languages.js +191 -34
  5. package/library/path-safety.js +51 -0
  6. package/library/request-token.js +56 -0
  7. package/package.json +2 -2
  8. package/packages/packages.js +17 -118
  9. package/publisher/publisher.js +60 -30
  10. package/publisher/validation.js +15 -3
  11. package/server.js +12 -3
  12. package/translations/Messages.properties +1 -0
  13. package/tx/README.md +21 -0
  14. package/tx/cs/cs-api.js +30 -1
  15. package/tx/cs/cs-cs.js +14 -1
  16. package/tx/cs/cs-icd11.js +844 -0
  17. package/tx/cs/cs-lang.js +331 -85
  18. package/tx/cs/cs-loinc.js +45 -4
  19. package/tx/cs/cs-mimetypes.js +492 -21
  20. package/tx/cs/cs-snomed.js +38 -4
  21. package/tx/cs/cs-ucum.js +33 -3
  22. package/tx/cs/icd11-expressions.js +243 -0
  23. package/tx/importers/export-icd11-codesystem.js +823 -0
  24. package/tx/importers/icd11-schema.md +285 -0
  25. package/tx/importers/import-icd11.js +861 -0
  26. package/tx/importers/import-loinc.module.js +14 -7
  27. package/tx/importers/import-sct.module.js +10 -1
  28. package/tx/library/codesystem.js +21 -27
  29. package/tx/library/nesting-limit.js +79 -0
  30. package/tx/library/operation-outcome.js +97 -3
  31. package/tx/library/valueset.js +16 -20
  32. package/tx/library.js +23 -1
  33. package/tx/ocl/cache/cache-utils.cjs +5 -3
  34. package/tx/ocl/cs-ocl.cjs +73 -17
  35. package/tx/ocl/jobs/background-queue.cjs +6 -3
  36. package/tx/ocl/mappers/concept-mapper.cjs +9 -1
  37. package/tx/ocl/shared/patches.cjs +48 -1
  38. package/tx/ocl/vs-ocl.cjs +15 -13
  39. package/tx/provider.js +1 -1
  40. package/tx/sct/expressions.js +194 -17
  41. package/tx/tx.fhir.org.yml +2 -1
  42. package/tx/tx.js +15 -42
  43. package/tx/workers/batch-validate.js +6 -16
  44. package/tx/workers/batch.js +16 -28
  45. package/tx/workers/cache-control.js +6 -12
  46. package/tx/workers/closure.js +3 -8
  47. package/tx/workers/compare.js +6 -34
  48. package/tx/workers/expand.js +36 -45
  49. package/tx/workers/lookup.js +21 -39
  50. package/tx/workers/read.js +6 -40
  51. package/tx/workers/search.js +2 -8
  52. package/tx/workers/subsumes.js +58 -41
  53. package/tx/workers/translate.js +11 -17
  54. package/tx/workers/validate.js +76 -77
  55. package/tx/workers/worker.js +70 -1
  56. package/tx/xml/xml-base.js +243 -76
  57. package/tx/xversion/xv-valueset.js +39 -1
@@ -62,7 +62,21 @@ class LanguageVariant extends LanguageEntry {}
62
62
  * Individual language representation based on BCP 47
63
63
  */
64
64
  class Language {
65
- constructor(code = '', languageDefinitions = null) {
65
+ /**
66
+ * @param {string} code - the language tag
67
+ * @param {LanguageDefinitions} [languageDefinitions] - registry to validate against; without
68
+ * one the tag is only broken into parts, not checked
69
+ * @param {object} [options] - extra checking, off by default so that existing callers
70
+ * (Accept-Language handling above all) keep the behaviour they were written against
71
+ * @param {boolean} [options.caseInsensitive] - fold subtags to their registry case before
72
+ * looking them up. BCP 47 s2.1.1 says tags are case-insensitive and the conventional
73
+ * casing is a recommendation, so `en-us` is a valid way to write `en-US`
74
+ * @param {boolean} [options.checkCombinations] - apply the registry's statements about
75
+ * which subtags may go together: the Prefix on an extlang or variant, and that a
76
+ * variant is registered at all
77
+ */
78
+ constructor(code = '', languageDefinitions = null, options = {}) {
79
+ this.options = options || {};
66
80
  this.code = code;
67
81
  this.language = '';
68
82
  this.extLang = [];
@@ -115,14 +129,26 @@ class Language {
115
129
  _parse(languageDefinitions) {
116
130
  if (!this.code) return;
117
131
 
132
+ // Grandfathered tags are whole tags, and most do not decompose into valid subtags
133
+ // (i-klingon has no language 'i'), so they have to be recognised before the tag is
134
+ // split up. Redundant entries are deliberately NOT here: zh-Hans decomposes, and
135
+ // matching it whole would rob the caller of the script.
136
+ if (languageDefinitions && languageDefinitions.wholeTags.has(this.code.toLowerCase())) {
137
+ const entry = languageDefinitions.wholeTags.get(this.code.toLowerCase());
138
+ this.language = entry.code;
139
+ this.wholeTag = entry;
140
+ return;
141
+ }
142
+
143
+ const fold = this.options.caseInsensitive;
118
144
  const parts = this.code.split('-');
119
145
  let index = 0;
120
146
 
121
147
  // Language (required)
122
148
  if (index < parts.length) {
123
- this.language = parts[index];
149
+ this.language = fold ? parts[index].toLowerCase() : parts[index];
124
150
  if (this.language != '*' && languageDefinitions && !languageDefinitions.languages.has(this.language)) {
125
- throw new Error("The language '"+this.language+"' in the code '"+this.code+"' is not valid");
151
+ throw new Error("The language '"+parts[index]+"' in the code '"+this.code+"' is not valid");
126
152
  }
127
153
  index++;
128
154
  }
@@ -130,10 +156,14 @@ class Language {
130
156
  // Extended language (up to 3)
131
157
  for (let i = 0; i < 3 && index < parts.length; i++) {
132
158
  const part = parts[index];
159
+ const key = fold ? part.toLowerCase() : part;
133
160
  if (part.length === 3 && /^[a-zA-Z]{3}$/.test(part)) {
134
- if (languageDefinitions && !languageDefinitions.extLanguages.has(part)) {
161
+ if (languageDefinitions && !languageDefinitions.extLanguages.has(key)) {
135
162
  throw new Error("The extLanguage '"+part+"' in the code '"+this.code+"' is not valid");
136
163
  }
164
+ if (this.options.checkCombinations && languageDefinitions) {
165
+ this._checkPrefix(languageDefinitions.extLanguages.get(key), 'extLanguage', part);
166
+ }
137
167
  this.extLang.push(part.toLowerCase());
138
168
  index++;
139
169
  } else {
@@ -145,7 +175,8 @@ class Language {
145
175
  if (index < parts.length) {
146
176
  const part = parts[index];
147
177
  if (part.length === 4 && /^[a-zA-Z]{4}$/.test(part)) {
148
- if (languageDefinitions && !languageDefinitions.scripts.has(part)) {
178
+ const key = fold ? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() : part;
179
+ if (languageDefinitions && !languageDefinitions.scripts.has(key)) {
149
180
  throw new Error("The script '"+part+"' in the code '"+this.code+"' is not valid");
150
181
  }
151
182
  this.script = part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
@@ -158,7 +189,8 @@ class Language {
158
189
  const part = parts[index];
159
190
  if ((part.length === 2 && /^[a-zA-Z]{2}$/.test(part)) ||
160
191
  (part.length === 3 && /^[0-9]{3}$/.test(part))) {
161
- if (languageDefinitions && !languageDefinitions.regions.has(part)) {
192
+ const key = fold ? part.toUpperCase() : part;
193
+ if (languageDefinitions && !languageDefinitions.regions.has(key)) {
162
194
  throw new Error("The region '"+part+"' in the code '"+this.code+"' is not valid");
163
195
  }
164
196
  this.region = part.toUpperCase();
@@ -171,7 +203,16 @@ class Language {
171
203
  const part = parts[index];
172
204
  if ((part.length >= 5 && part.length <= 8) ||
173
205
  (part.length === 4 && /^[0-9]/.test(part))) {
174
- this.variant = part.toLowerCase();
206
+ const key = part.toLowerCase();
207
+ if (this.options.checkCombinations && languageDefinitions) {
208
+ // The variant is the one subtag whose existence was never checked at all -
209
+ // 'en-abcdef' parsed happily with variant 'abcdef'.
210
+ if (!languageDefinitions.variants.has(key)) {
211
+ throw new Error("The variant '"+part+"' in the code '"+this.code+"' is not valid");
212
+ }
213
+ this._checkPrefix(languageDefinitions.variants.get(key), 'variant', part);
214
+ }
215
+ this.variant = key;
175
216
  index++;
176
217
  }
177
218
  }
@@ -197,6 +238,36 @@ class Language {
197
238
  }
198
239
  }
199
240
 
241
+ /**
242
+ * The registry's Prefix field names the language(s) a subtag may follow; it is the only
243
+ * thing lang.dat says about which subtags go together. An entry with no Prefix may
244
+ * follow anything.
245
+ *
246
+ * The prefix itself may be a multi-subtag tag (variant 'nedis' has Prefix 'sl-rozaj'),
247
+ * so what has been parsed so far is reassembled and compared from the left.
248
+ *
249
+ * @param {object} entry - the registry entry, carrying `prefixes`
250
+ * @param {string} what - 'extLanguage' or 'variant', for the message
251
+ * @param {string} part - the subtag as written, for the message
252
+ * @private
253
+ */
254
+ _checkPrefix(entry, what, part) {
255
+ if (!entry || !entry.prefixes || entry.prefixes.length === 0) {
256
+ return;
257
+ }
258
+ const soFar = [this.language.toLowerCase()]
259
+ .concat(this.extLang.map(e => e.toLowerCase()))
260
+ .concat(this.script ? [this.script.toLowerCase()] : [])
261
+ .concat(this.region ? [this.region.toLowerCase()] : [])
262
+ .join('-');
263
+ const ok = entry.prefixes.some(prefix => soFar === prefix || soFar.startsWith(prefix + '-'));
264
+ if (!ok) {
265
+ throw new Error("The " + what + " '" + part + "' in the code '" + this.code
266
+ + "' may only be used with " + entry.prefixes.map(p => "'" + p + "'").join(' or ')
267
+ + ", not with '" + soFar + "'");
268
+ }
269
+ }
270
+
200
271
  /**
201
272
  * Check if this language matches another to a given depth
202
273
  */
@@ -503,9 +574,63 @@ class LanguageDefinitions {
503
574
  this.scripts = new Map();
504
575
  this.regions = new Map();
505
576
  this.variants = new Map();
577
+ // Whole tags that predate the current syntax (i-klingon, en-GB-oed) or that the
578
+ // registry records as ready-made combinations (az-Arab). They are matched as
579
+ // complete tags, before the tag is broken into subtags, because most of them do
580
+ // not decompose into valid subtags at all. Keyed lower case: BCP 47 tags are
581
+ // case-insensitive, and the registry writes these in mixed case.
582
+ this.wholeTags = new Map();
506
583
  this.parsed = new Map(); // Cache for parsed languages
507
584
  }
508
585
 
586
+ /**
587
+ * Expand a registry subtag range into its members.
588
+ *
589
+ * The registry writes private-use allocations as ranges - `QM..QZ`, `Qaaa..Qabx`,
590
+ * `qaa..qtz`, `XA..XZ` - in a single record. Stored literally, the key `QM..QZ` is
591
+ * one nobody will ever look up, and every code in the range reads as unregistered:
592
+ * `en-QM` was rejected while `en-AA` (allocated singly) was accepted.
593
+ *
594
+ * Only the trailing letters vary, so the shared prefix is kept and the remainder is
595
+ * counted through base 26, rendered in the case the range itself uses.
596
+ *
597
+ * @param {string} subtag - a subtag, with or without `..`
598
+ * @returns {string[]} every code the entry covers; a single-element array if not a range
599
+ */
600
+ static expandSubtagRange(subtag) {
601
+ const at = subtag.indexOf('..');
602
+ if (at === -1) {
603
+ return [subtag];
604
+ }
605
+ const from = subtag.substring(0, at);
606
+ const to = subtag.substring(at + 2);
607
+ if (from.length !== to.length || !/^[a-zA-Z]+$/.test(from) || !/^[a-zA-Z]+$/.test(to)) {
608
+ return [subtag];
609
+ }
610
+ let shared = 0;
611
+ while (shared < from.length && from[shared] === to[shared]) {
612
+ shared++;
613
+ }
614
+ const prefix = from.substring(0, shared);
615
+ const upper = /[A-Z]/.test(from.substring(shared));
616
+ const A = 'a'.charCodeAt(0);
617
+ const toIndex = (text) => [...text.toLowerCase()].reduce((n, ch) => n * 26 + (ch.charCodeAt(0) - A), 0);
618
+ const width = from.length - shared;
619
+ const render = (n) => {
620
+ let out = '';
621
+ for (let i = 0; i < width; i++) {
622
+ out = String.fromCharCode(A + (n % 26)) + out;
623
+ n = Math.floor(n / 26);
624
+ }
625
+ return upper ? out.toUpperCase() : out;
626
+ };
627
+ const result = [];
628
+ for (let n = toIndex(from.substring(shared)); n <= toIndex(to.substring(shared)); n++) {
629
+ result.push(prefix + render(n));
630
+ }
631
+ return result;
632
+ }
633
+
509
634
  /**
510
635
  * Load definitions from IETF language subtag registry file
511
636
  */
@@ -564,8 +689,12 @@ class LanguageDefinitions {
564
689
  this._loadVariant(vars);
565
690
  break;
566
691
  case 'grandfathered':
692
+ this._loadWholeTag(vars);
693
+ break;
567
694
  case 'redundant':
568
- // Skip these for now
695
+ // Nothing to do: a redundant entry is called that because it decomposes into
696
+ // subtags that already say the same thing (zh-Hans is zh + Hans), so the
697
+ // normal path handles it and gives the caller the parts as well.
569
698
  break;
570
699
  default:
571
700
  throw new Error(`Unknown type: ${vars.Type} at line ${i + 1}`);
@@ -576,6 +705,24 @@ class LanguageDefinitions {
576
705
  }
577
706
  }
578
707
 
708
+ /**
709
+ * Load a grandfathered entry - a whole tag from before the current syntax, most of
710
+ * which cannot be decomposed into valid subtags at all ('i-klingon' has no language
711
+ * 'i'), so they are matched entire.
712
+ */
713
+ _loadWholeTag(vars) {
714
+ const tag = vars.Tag || vars.Subtag;
715
+ if (!tag) {
716
+ return;
717
+ }
718
+ this.wholeTags.set(tag.toLowerCase(), {
719
+ code: tag,
720
+ displays: vars.Description ? vars.Description.split('|') : [],
721
+ deprecated: !!vars.Deprecated,
722
+ preferred: vars['Preferred-Value'] || ''
723
+ });
724
+ }
725
+
579
726
  /**
580
727
  * Read variables from the registry format
581
728
  */
@@ -696,11 +843,12 @@ class LanguageDefinitions {
696
843
  lang.suppressScript = vars['Suppress-Script'] || '';
697
844
  lang.scope = vars.Scope || '';
698
845
 
699
- if (this.languages.has(lang.code)) {
700
- throw new Error(`Duplicate language code: ${lang.code}`);
846
+ for (const code of LanguageDefinitions.expandSubtagRange(lang.code)) {
847
+ if (this.languages.has(code)) {
848
+ throw new Error(`Duplicate language code: ${code}`);
849
+ }
850
+ this.languages.set(code, lang.code === code ? lang : Object.assign(Object.create(Object.getPrototypeOf(lang)), lang, { code }));
701
851
  }
702
-
703
- this.languages.set(lang.code, lang);
704
852
  }
705
853
 
706
854
  /**
@@ -710,12 +858,15 @@ class LanguageDefinitions {
710
858
  const extLang = new LanguageExtLang();
711
859
  extLang.code = vars.Subtag;
712
860
  extLang.displays = vars.Description ? vars.Description.split('|') : [];
861
+ // Prefix is the registry saying which language(s) this subtag may follow
862
+ extLang.prefixes = vars.Prefix ? vars.Prefix.split('|').map(v => v.trim().toLowerCase()) : [];
713
863
 
714
- if (this.extLanguages.has(extLang.code)) {
715
- throw new Error(`Duplicate extlang code: ${extLang.code}`);
864
+ for (const code of LanguageDefinitions.expandSubtagRange(extLang.code)) {
865
+ if (this.extLanguages.has(code)) {
866
+ throw new Error(`Duplicate extlang code: ${code}`);
867
+ }
868
+ this.extLanguages.set(code, extLang.code === code ? extLang : Object.assign(Object.create(Object.getPrototypeOf(extLang)), extLang, { code }));
716
869
  }
717
-
718
- this.extLanguages.set(extLang.code, extLang);
719
870
  }
720
871
 
721
872
  /**
@@ -726,11 +877,12 @@ class LanguageDefinitions {
726
877
  script.code = vars.Subtag;
727
878
  script.displays = vars.Description ? vars.Description.split('|') : [];
728
879
 
729
- if (this.scripts.has(script.code)) {
730
- throw new Error(`Duplicate script code: ${script.code}`);
880
+ for (const code of LanguageDefinitions.expandSubtagRange(script.code)) {
881
+ if (this.scripts.has(code)) {
882
+ throw new Error(`Duplicate script code: ${code}`);
883
+ }
884
+ this.scripts.set(code, script.code === code ? script : Object.assign(Object.create(Object.getPrototypeOf(script)), script, { code }));
731
885
  }
732
-
733
- this.scripts.set(script.code, script);
734
886
  }
735
887
 
736
888
  /**
@@ -741,11 +893,12 @@ class LanguageDefinitions {
741
893
  region.code = vars.Subtag;
742
894
  region.displays = vars.Description ? vars.Description.split('|') : [];
743
895
 
744
- if (this.regions.has(region.code)) {
745
- throw new Error(`Duplicate region code: ${region.code}`);
896
+ for (const code of LanguageDefinitions.expandSubtagRange(region.code)) {
897
+ if (this.regions.has(code)) {
898
+ throw new Error(`Duplicate region code: ${code}`);
899
+ }
900
+ this.regions.set(code, region.code === code ? region : Object.assign(Object.create(Object.getPrototypeOf(region)), region, { code }));
746
901
  }
747
-
748
- this.regions.set(region.code, region);
749
902
  }
750
903
 
751
904
  /**
@@ -755,12 +908,14 @@ class LanguageDefinitions {
755
908
  const variant = new LanguageVariant();
756
909
  variant.code = vars.Subtag;
757
910
  variant.displays = vars.Description ? vars.Description.split('|') : [];
911
+ variant.prefixes = vars.Prefix ? vars.Prefix.split('|').map(v => v.trim().toLowerCase()) : [];
758
912
 
759
- if (this.variants.has(variant.code)) {
760
- throw new Error(`Duplicate variant code: ${variant.code}`);
913
+ for (const code of LanguageDefinitions.expandSubtagRange(variant.code)) {
914
+ if (this.variants.has(code)) {
915
+ throw new Error(`Duplicate variant code: ${code}`);
916
+ }
917
+ this.variants.set(code, variant.code === code ? variant : Object.assign(Object.create(Object.getPrototypeOf(variant)), variant, { code }));
761
918
  }
762
-
763
- this.variants.set(variant.code, variant);
764
919
  }
765
920
 
766
921
  /**
@@ -768,7 +923,7 @@ class LanguageDefinitions {
768
923
  *
769
924
  * @return {Language} parsed language (or null)
770
925
  */
771
- parse(code, msg) {
926
+ parse(code, msg, options = {}) {
772
927
  if (!code) {
773
928
  if (msg) {
774
929
  msg.message = 'No code provided';
@@ -776,15 +931,17 @@ class LanguageDefinitions {
776
931
  return null;
777
932
  }
778
933
 
779
- // Check cache first
780
- if (this.parsed.has(code)) {
781
- return this.parsed.get(code);
934
+ // The cache key carries the options: the same tag can be valid under one set of
935
+ // rules and not another, so a lenient parse must not be served to a strict caller.
936
+ const key = (options.caseInsensitive ? 'i' : '') + (options.checkCombinations ? 'c' : '') + ':' + code;
937
+ if (this.parsed.has(key)) {
938
+ return this.parsed.get(key);
782
939
  }
783
940
 
784
941
  try {
785
- const lang = new Language(code, this);
942
+ const lang = new Language(code, this, options);
786
943
  // Cache the result
787
- this.parsed.set(code, lang);
944
+ this.parsed.set(key, lang);
788
945
  return lang;
789
946
  } catch (e) {
790
947
  if (msg) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Containment for filesystem paths built from request URLs.
3
+ *
4
+ * The rule everywhere a URL path is turned into a filename: resolve first, then check
5
+ * that the result is still inside the directory you meant to serve. `path.join` does
6
+ * not do this - it resolves `..` happily and will walk straight out of the directory.
7
+ *
8
+ * Note that Express does not URL-decode `req.path`, so `%2e%2e` never arrives here as
9
+ * `..`. A literal `..` does: nothing stops a client from sending `GET /../../etc/passwd`
10
+ * on a raw socket, and only the browsers and proxies in between normalise it away.
11
+ *
12
+ * @module library/path-safety
13
+ */
14
+
15
+ const path = require('path');
16
+
17
+ /**
18
+ * Resolve a URL path against a root directory, refusing anything that escapes it.
19
+ *
20
+ * The trailing separator in the comparison is load-bearing: without it a sibling
21
+ * directory whose name merely starts with the same characters ('/srv/site' vs
22
+ * '/srv/site-backup') would be accepted as inside the root.
23
+ *
24
+ * @param {string} rootDir - Directory that the result must stay inside. Resolved first,
25
+ * so a relative or non-normalised root is fine.
26
+ * @param {string} urlPath - Path from the request (e.g. `req.path`), leading slash and all.
27
+ * @returns {string|null} The absolute path, or null if it would escape rootDir. The root
28
+ * directory itself is allowed through; callers that only want files should stat it.
29
+ */
30
+ function resolveWithin(rootDir, urlPath) {
31
+ if (typeof rootDir !== 'string' || typeof urlPath !== 'string') {
32
+ return null;
33
+ }
34
+ // A NUL truncates the name inside libc, so a path containing one is never what it
35
+ // appears to be. Node throws on these anyway; refusing here keeps the answer a null
36
+ // rather than an exception from whatever the caller does next.
37
+ if (urlPath.includes('\0')) {
38
+ return null;
39
+ }
40
+ const root = path.resolve(rootDir);
41
+ // '.' + urlPath keeps the join relative: resolve() would otherwise treat a leading
42
+ // slash as an absolute path and discard the root entirely.
43
+ const full = path.resolve(root, '.' + (urlPath.startsWith('/') ? urlPath : '/' + urlPath));
44
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
45
+ if (full !== root && !full.startsWith(prefix)) {
46
+ return null;
47
+ }
48
+ return full;
49
+ }
50
+
51
+ module.exports = { resolveWithin };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared-secret checks for administrative endpoints.
3
+ *
4
+ * These are endpoints that do something expensive or destructive and have no user
5
+ * behind them - a crawl trigger, a cache flush - so they are gated on a token from the
6
+ * configuration rather than on a login. Two rules make that safe, and both are easy to
7
+ * get wrong by hand:
8
+ *
9
+ * - Fail closed. If no token is configured the endpoint is CLOSED, not open. An
10
+ * administrative endpoint that quietly defaults to unauthenticated is worse than one
11
+ * that is missing, because nothing about the deployment looks wrong.
12
+ * - Compare in constant time. A byte-at-a-time comparison leaks the token one
13
+ * character per round trip to anyone who can measure it.
14
+ *
15
+ * @module library/request-token
16
+ */
17
+
18
+ const crypto = require('crypto');
19
+
20
+ /**
21
+ * Does `supplied` match the configured token?
22
+ *
23
+ * @param {string|undefined|null} configured - The token from configuration. Anything
24
+ * empty or non-string means the endpoint is not configured, and the answer is always
25
+ * false - callers should say so rather than reporting a bad token.
26
+ * @param {*} supplied - The token from the request (a header value). Non-strings, and
27
+ * the array Node produces for a repeated header, are rejected.
28
+ * @returns {boolean}
29
+ */
30
+ function tokenMatches(configured, supplied) {
31
+ if (typeof configured !== 'string' || configured.length === 0) {
32
+ return false;
33
+ }
34
+ if (typeof supplied !== 'string' || supplied.length === 0) {
35
+ return false;
36
+ }
37
+ const a = Buffer.from(configured, 'utf8');
38
+ const b = Buffer.from(supplied, 'utf8');
39
+ // timingSafeEqual throws unless the lengths match, and the length itself is not a
40
+ // secret worth protecting - but returning early on it would still be a comparison
41
+ // whose cost depends on the input, so hash both to a fixed width and compare that.
42
+ const ha = crypto.createHash('sha256').update(a).digest();
43
+ const hb = crypto.createHash('sha256').update(b).digest();
44
+ return crypto.timingSafeEqual(ha, hb);
45
+ }
46
+
47
+ /**
48
+ * Is this endpoint configured with a token at all?
49
+ * @param {string|undefined|null} configured
50
+ * @returns {boolean}
51
+ */
52
+ function tokenConfigured(configured) {
53
+ return typeof configured === 'string' && configured.length > 0;
54
+ }
55
+
56
+ module.exports = { tokenMatches, tokenConfigured };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.12.0",
4
- "txVersion": "1.9.3",
3
+ "version": "0.13.1",
4
+ "txVersion": "1.9.4",
5
5
  "description": "A Node.js server that provides a collection of tools to serve the FHIR ecosystem",
6
6
  "main": "server.js",
7
7
  "engines": {
@@ -16,6 +16,7 @@ const escape = require('escape-html');
16
16
  const Logger = require('../library/logger');
17
17
  const {validateParameter} = require("../library/utilities");
18
18
  const {describeCron} = require("../library/cron-utilities");
19
+ const {tokenMatches, tokenConfigured} = require("../library/request-token");
19
20
  const pckLog = Logger.getInstance().child({ module: 'packages' });
20
21
 
21
22
  class PackagesModule {
@@ -592,75 +593,6 @@ class PackagesModule {
592
593
  }
593
594
  }
594
595
 
595
- // Re-fetch a single package tarball and replace whatever is stored for it, bypassing
596
- // the crawler's GUID dedup and notForPublication feed gate. Used to push out a
597
- // corrected package that was already (mis)published.
598
- async forceUpdatePackage(link) {
599
- // Only allow fetching over http(s). This endpoint must never be usable to read
600
- // local server files (path injection) - tarballs are always published web URLs.
601
- if (!link || !/^https?:\/\//i.test(link)) {
602
- throw new Error('Invalid package link (must be an http(s) URL): ' + link);
603
- }
604
- if (!this.crawler) {
605
- this.crawler = new PackageCrawler(this.config, this.db, this.stats);
606
- }
607
-
608
- const buffer = await this.crawler.fetchUrl(link);
609
- const npm = await this.crawler.extractNpmPackage(buffer, link);
610
-
611
- // Refuse to re-store a still-broken package - that would just re-publish the bug.
612
- if (npm.notForPublication) {
613
- throw new Error('Refusing to store ' + npm.id + '#' + npm.version + ': fetched tarball is still flagged notForPublication');
614
- }
615
-
616
- const idver = npm.id + '#' + npm.version;
617
-
618
- // Delete whatever is stored for this package version, matching on the id#version
619
- // actually found in the fetched tarball. This used to match on GUID = link, but the
620
- // stored GUID is whatever the feed said at crawl time (http vs https, or another
621
- // permalink form), so an exact string match could silently delete nothing and leave
622
- // the stale copy in place alongside the newly stored one.
623
- const old = await this.deleteVersionsByIdVersion(npm.id, npm.version);
624
-
625
- // Keep the old row's GUID when there was one - it is the feed's permalink, and
626
- // reusing it stops the next crawl from re-inserting the package as a duplicate.
627
- // When we never had the package, the link is the best available GUID (the feeds
628
- // use the versioned package.tgz url as the permalink GUID anyway).
629
- const guid = old.guids[0] || link;
630
-
631
- const itemLog = { status: '??' };
632
- await this.crawler.store(link, link, guid, new Date(), buffer, idver, itemLog);
633
-
634
- pckLog.info('Force-updated ' + idver + ' from ' + link + ' (replaced ' + old.count + ' existing row(s), guid ' + guid + ')');
635
- return { status: 'updated', id: npm.id, version: npm.version, replaced: old.count };
636
- }
637
-
638
- // Delete a stored package version and all of its child rows, by package id and
639
- // version. Resolves to { count, guids }: how many PackageVersions rows went, and
640
- // the distinct GUIDs they were stored under (so a caller can reuse the permalink).
641
- deleteVersionsByIdVersion(id, version) {
642
- return new Promise((resolve, reject) => {
643
- this.db.all('SELECT PackageVersionKey, GUID FROM PackageVersions WHERE Id = ? AND Version = ?', [id, version], (err, rows) => {
644
- if (err) return reject(err);
645
- const keys = (rows || []).map(r => r.PackageVersionKey);
646
- const guids = [...new Set((rows || []).map(r => r.GUID))];
647
- if (keys.length === 0) return resolve({ count: 0, guids: [] });
648
- const ph = keys.map(() => '?').join(',');
649
- const stmts = [
650
- 'DELETE FROM PackageFHIRVersions WHERE PackageVersionKey IN (' + ph + ')',
651
- 'DELETE FROM PackageDependencies WHERE PackageVersionKey IN (' + ph + ')',
652
- 'DELETE FROM PackageURLs WHERE PackageVersionKey IN (' + ph + ')',
653
- 'DELETE FROM PackageVersions WHERE PackageVersionKey IN (' + ph + ')'
654
- ];
655
- const runNext = (i) => {
656
- if (i >= stmts.length) return resolve({ count: keys.length, guids });
657
- this.db.run(stmts[i], keys, (e) => e ? reject(e) : runNext(i + 1));
658
- };
659
- runNext(0);
660
- });
661
- });
662
- }
663
-
664
596
  async initializeDatabase() {
665
597
  return new Promise((resolve, reject) => {
666
598
  // Use config path if absolute, otherwise resolve relative to data dir
@@ -1269,10 +1201,25 @@ class PackagesModule {
1269
1201
  }
1270
1202
  });
1271
1203
 
1272
- // Manual crawler trigger (existing)
1204
+ // Manual crawler trigger. A crawl is a long, network-heavy job that anyone could
1205
+ // otherwise start at will, so it is gated on a shared secret from the configuration
1206
+ // (modules.packages.crawlToken), sent in the x-crawl-token header. With no token
1207
+ // configured the endpoint is CLOSED - an administrative trigger that silently
1208
+ // defaults to open is how a deployment ends up exposed without anything looking wrong.
1273
1209
  this.router.post('/crawl', async (req, res) => {
1274
1210
  const start = Date.now();
1275
1211
  try {
1212
+ if (!tokenConfigured(this.config.crawlToken)) {
1213
+ // Say which of the two it is: the operator needs to know the endpoint is off
1214
+ // because it was never configured, not that they typed the token wrongly.
1215
+ res.status(403).json({ error: 'forbidden: no crawlToken is configured, so this endpoint is disabled' });
1216
+ return;
1217
+ }
1218
+ if (!tokenMatches(this.config.crawlToken, req.headers['x-crawl-token'])) {
1219
+ pckLog.warn('Rejected /crawl: missing or invalid x-crawl-token (from ' + req.ip + ')');
1220
+ res.status(403).json({ error: 'forbidden: missing or invalid x-crawl-token' });
1221
+ return;
1222
+ }
1276
1223
  try {
1277
1224
  await this.runCrawler();
1278
1225
  res.json({
@@ -1292,54 +1239,6 @@ class PackagesModule {
1292
1239
  }
1293
1240
  });
1294
1241
 
1295
- // Force-refresh specific packages, bypassing the feed. The crawler only fetches a
1296
- // package once (it dedupes on the feed GUID) and skips anything flagged
1297
- // notForPublication, so there is normally no way to make it re-pick-up a package
1298
- // that was published incorrectly and later corrected. This endpoint re-fetches the
1299
- // tarball(s) directly and replaces whatever is stored.
1300
- //
1301
- // POST /update-package { "links": ["http://hl7.org/fhir/uv/ips/2.0.1/package.tgz", ...] }
1302
- //
1303
- // The link is the versioned package.tgz url. The stored copy to replace is found by
1304
- // the id#version inside the fetched tarball (not by matching the link against the
1305
- // stored GUID, which can differ in scheme or form from what the feed published), and
1306
- // the replacement keeps the old row's GUID so a later crawl won't create a duplicate.
1307
- // If config.updateToken is set, the request must carry it in the x-update-token header.
1308
- this.router.post('/update-package', async (req, res) => {
1309
- const start = Date.now();
1310
- try {
1311
- if (this.config.updateToken && req.headers['x-update-token'] !== this.config.updateToken) {
1312
- res.status(403).json({ error: 'forbidden: missing or invalid x-update-token' });
1313
- return;
1314
- }
1315
- let links = req.body && (req.body.links || req.body.packages || (req.body.url ? [req.body.url] : (req.body.link ? [req.body.link] : null)));
1316
- if (typeof links === 'string') links = [links];
1317
- if (!Array.isArray(links) || links.length === 0) {
1318
- res.status(400).json({ error: 'Provide a JSON body like {"links": ["<package.tgz url>", ...]}' });
1319
- return;
1320
- }
1321
- const results = [];
1322
- for (const link of links) {
1323
- try {
1324
- results.push(Object.assign({ link }, await this.forceUpdatePackage(link)));
1325
- } catch (e) {
1326
- pckLog.error('Force update failed for ' + link + ': ' + e.message);
1327
- results.push({ link, status: 'error', error: e.message });
1328
- }
1329
- }
1330
- const failed = results.filter(r => r.status === 'error').length;
1331
- res.status(failed === results.length ? 500 : 200).json({
1332
- message: 'Processed ' + results.length + ' package(s), ' + failed + ' failed',
1333
- results
1334
- });
1335
- } catch (error) {
1336
- pckLog.error('update-package endpoint failed:', error);
1337
- res.status(500).json({ error: 'update-package failed', message: error.message });
1338
- } finally {
1339
- this.stats.countRequest('update-package', Date.now() - start);
1340
- }
1341
- });
1342
-
1343
1242
  // Crawler statistics endpoint (existing)
1344
1243
  this.router.get('/stats', async (req, res) => {
1345
1244
  const start = Date.now();