fhirsmith 0.11.1 → 0.12.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,91 @@ All notable changes to the Health Intersections Node Server will be documented i
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.12.0] - 2026-08-27
9
+
10
+ ### Added
11
+
12
+ - `$cache-control?mode=check`: ask the server whether a cache-id is still alive. The check is
13
+ also a keepalive - it resets the cache's idle clock - because a client whose own local cache is
14
+ absorbing the work is invisible to the server, and its cache would otherwise time out mid-job.
15
+ An unknown id answers 200 with `valid` = false (not 404), so a client can tell "the server says
16
+ my cache is gone" from "I could not reach the server"; a live cache also reports `sealed`,
17
+ `resource-count`, `idle` and the server's idle timeout, so a client can work out how often to
18
+ check instead of guessing
19
+ - Cache tombstones: the server now records why a cache-id it issued stopped existing - closed by
20
+ the client, expired after N minutes idle, or cleared with everything else - and says which in
21
+ the error, with the timings. New `CACHE_ID_CLOSED`, `CACHE_ID_EXPIRED` and `CACHE_ID_CLEARED`
22
+ messages; `CACHE_ID_UNKNOWN` now means "never issued here", and says that a cache-id is only
23
+ valid on the server instance and endpoint that issued it (#279)
24
+ - SNOMED CT: MRCM validation of postcoordinated expressions - attribute domain (including the
25
+ lateralizable body structure rule), range (evaluated as ECL, or as a concrete-value range such
26
+ as `dec(>#0..)`), cardinality and grouping. A constraint that cannot be resolved leaves the
27
+ value unchecked rather than rejecting it. The MRCM and lateralizable reference sets are now
28
+ included in the test SNOMED distribution
29
+ - `$translate`: R4 parameter names are accepted as aliases for the R5 ones (`source`/`target` for
30
+ the scopes, `targetsystem`, `targetcode`), and R4's `reverse` parameter now does literally what
31
+ it says - swaps the source and target sides once the parameters are read - while remaining an
32
+ error in R5+, which names the target concept directly instead
33
+ - `$translate`: reverse translation by naming the target concept (`targetCode`, `targetCoding`,
34
+ `targetCodeableConcept`), with the source system saying which system the answers come from
35
+ - `$translate`: `ConceptMap.group.element.comment` is read from R6 resources and from the
36
+ cross-version extension on R5 ones, so a preadopted R5 map and a native R6 map behave the same
37
+ - `$translate`: `originMap` names where a chain of maps started, with every other map consulted
38
+ along the way reported as a `used-conceptmap`; `group.unmapped` is applied per group, so a
39
+ mapping in one group no longer suppresses another group's fallback
40
+ - Persistent usage statistics: request counts per module, endpoint and operation are written to a
41
+ SQLite database every `intervalMinutes`, with both the interval count and the all-time total, so
42
+ they survive restarts and upgrades. New optional `stats` config block (#255)
43
+ - Support for `ValueSet.compose.property` (R6): a value set can name the properties to return in
44
+ its own expansion, rather than leaving it to the request
45
+ - Publisher: input validation for GitHub owner and repository, git branch, package id and version -
46
+ enforced on the server, with matching patterns on the form so the browser objects first
47
+ - Publisher: `large-file-archive` config setting - files the IG Publisher leaves in the web output
48
+ that are too big for GitHub (>100MB) are moved aside for GitHub-hosted websites, instead of
49
+ leaving the push to fail
50
+
51
+ ### Changed
52
+
53
+ - `tx/params.js`: parameter names are now interpreted in exactly one place (`seeParameter`), used
54
+ by both the request `Parameters` resource and the `valueset-expansion-parameter` extension. A
55
+ parameter from the request always wins over one embedded in a ValueSet's expansion parameters;
56
+ accumulating parameters (version rules, designations, properties, supplements) still add
57
+ - An inactive display is now governed by `lenient-display-validation` like every other display
58
+ check - a warning (and `result` = true) when lenient, an error when not - rather than always
59
+ being a warning. The display is a designation of the concept, just not a current one
60
+ - tx.fhir.org now loads `fhir.tx.support` rather than `fhir.tx.support.r4`
61
+
62
+ ### Fixed
63
+
64
+ - `$expand`: `status` was lost from imported property declarations, and a concept carrying the
65
+ same property more than once had the repeats collapsed to a single value
66
+ - Expansion properties are de-duplicated when they arrive from more than one place (the request,
67
+ an expansion parameter extension, `compose.property`) - a repeat emitted the property twice and
68
+ changed the cache key
69
+ - `no-cache=true` never busted the cache: the parameter wrote `uid`, which nothing read, instead of
70
+ the field the cache key hashes
71
+ - Boolean parameters passed as strings (as they always are on a GET) are now accepted, which
72
+ revives five parameters that were dead on GET requests
73
+
74
+ ### Tx Conformance Statement
75
+
76
+ FHIRsmith passed all 2822 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.3)
77
+
78
+ ## [0.11.2] - 2026-08-12
79
+
80
+ ### Fixed
81
+
82
+ - Update snomed test content
83
+
84
+ ### Fixed
85
+
86
+ - Fix snomed designation handling for languages
87
+ - Fix for SSRF protection in the validator-
88
+
89
+ ### Tx Conformance Statement
90
+
91
+ FHIRsmith passed all 2729 HL7 terminology service tests (modes tx.fhir.org+omop+general+snomed, tests v1.9.3, runner v6.10.1)
92
+
8
93
  ## [0.11.1] - 2026-07-30
9
94
 
10
95
  ### Added
@@ -6,6 +6,18 @@
6
6
  "credentials": true
7
7
  }
8
8
  },
9
+ // Usage statistics. Request counts are kept per module, per endpoint and per
10
+ // operation, and written to a SQLite database every intervalMinutes so they
11
+ // survive restarts and upgrades. Each row carries both the count for that
12
+ // interval and the all-time total. The whole block is optional.
13
+ "stats": {
14
+ // set false to keep statistics in memory only (nothing is written to disk)
15
+ "enabled": true,
16
+ // relative paths resolve under the data folder's databases directory
17
+ "database": "stats.db",
18
+ // how often the counters are written out
19
+ "intervalMinutes": 10
20
+ },
9
21
  "modules": {
10
22
  "shl": {
11
23
  "enabled": false,
@@ -64,7 +76,12 @@
64
76
  "database": "/absolute/path/to/publisher.db",
65
77
  "sessionSecret": "change-this-to-a-secure-random-string",
66
78
  "workspaceRoot": "/absolute/path/to/workspaces",
67
- "igPublisherTimeoutMinutes": 60
79
+ "igPublisherTimeoutMinutes": 60,
80
+ // where to put files the IG Publisher leaves in the web folder that are too big for GitHub
81
+ // (>100MB). Only applies to websites whose publish-setup.json has $.website.github = true.
82
+ // Omit to leave them in place - the push will then fail. Relative paths resolve under the
83
+ // publisher data folder
84
+ "large-file-archive": "/absolute/path/to/large-file-archive"
68
85
  },
69
86
  "npmprojector": {
70
87
  "enabled": true,
@@ -2,6 +2,8 @@ const fs = require('fs').promises;
2
2
  const path = require('path');
3
3
  const { PackageContentLoader } = require('./package-manager');
4
4
 
5
+ const TERMINOLOGY_TYPES = ['CodeSystem', 'ValueSet', 'ConceptMap'];
6
+
5
7
  /**
6
8
  * A content loader that scans a folder for JSON FHIR terminology resources
7
9
  * (CodeSystem, ValueSet, ConceptMap). It synthesizes the package layout that
@@ -26,14 +28,27 @@ class FolderContentLoader extends PackageContentLoader {
26
28
  }
27
29
 
28
30
  /**
29
- * Replaces the standard initialize: there is no package.json or .index.json
30
- * to read, so we synthesize an index from the JSON files in the folder.
31
+ * The synthesized package name reported by id()/pid().
32
+ * @returns {string}
31
33
  */
32
- async initialize() {
33
- if (this.loaded) {
34
- return;
35
- }
34
+ _packageName() {
35
+ return 'folder.' + path.basename(this.sourceFolder);
36
+ }
37
+
38
+ /**
39
+ * Whether a file that can't be parsed, or that isn't a terminology resource,
40
+ * should be quietly skipped. True for folder scans (the folder may hold all
41
+ * sorts of things); false when a specific file was nominated.
42
+ * @returns {boolean}
43
+ */
44
+ get _skipInvalidFiles() {
45
+ return true;
46
+ }
36
47
 
48
+ /**
49
+ * Check the source exists and is the right kind of thing.
50
+ */
51
+ async _validateSource() {
37
52
  try {
38
53
  const stat = await fs.stat(this.sourceFolder);
39
54
  if (!stat.isDirectory()) {
@@ -45,36 +60,63 @@ class FolderContentLoader extends PackageContentLoader {
45
60
  }
46
61
  throw err;
47
62
  }
63
+ }
64
+
65
+ /**
66
+ * The file names (relative to sourceFolder) to consider.
67
+ * @returns {Promise<Array<string>>}
68
+ */
69
+ async _candidateFiles() {
70
+ const names = [];
71
+ const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
72
+ for (const entry of entries) {
73
+ if (!entry.isFile()) continue;
74
+ if (!entry.name.toLowerCase().endsWith('.json')) continue;
75
+ names.push(entry.name);
76
+ }
77
+ return names;
78
+ }
79
+
80
+ /**
81
+ * Replaces the standard initialize: there is no package.json or .index.json
82
+ * to read, so we synthesize an index from the JSON files in the source.
83
+ */
84
+ async initialize() {
85
+ if (this.loaded) {
86
+ return;
87
+ }
88
+
89
+ await this._validateSource();
48
90
 
49
91
  // Synthesize package metadata so id()/version()/pid() return something usable.
50
92
  this.package = {
51
- name: 'folder.' + path.basename(this.sourceFolder),
93
+ name: this._packageName(),
52
94
  version: '0.0.0',
53
95
  fhirVersions: ['5.0.0']
54
96
  };
55
97
 
56
98
  const files = [];
57
- const entries = await fs.readdir(this.sourceFolder, { withFileTypes: true });
58
- for (const entry of entries) {
59
- if (!entry.isFile()) continue;
60
- if (!entry.name.toLowerCase().endsWith('.json')) continue;
61
-
62
- const fullPath = path.join(this.sourceFolder, entry.name);
99
+ for (const name of await this._candidateFiles()) {
100
+ const fullPath = path.join(this.sourceFolder, name);
63
101
  let json;
64
102
  try {
65
103
  const content = await fs.readFile(fullPath, 'utf8');
66
104
  json = JSON.parse(content);
67
- } catch {
68
- // Skip unreadable / non-JSON files rather than failing the whole load.
69
- continue;
105
+ } catch (e) {
106
+ // Skip unreadable / non-JSON files rather than failing the whole load,
107
+ // unless this exact file was the nominated source.
108
+ if (this._skipInvalidFiles) continue;
109
+ throw new Error(`Unable to read FHIR resource from ${fullPath}: ${e.message}`);
70
110
  }
71
111
 
72
- if (!json || typeof json !== 'object') continue;
73
- const rt = json.resourceType;
74
- if (rt !== 'CodeSystem' && rt !== 'ValueSet' && rt !== 'ConceptMap') continue;
112
+ const rt = json && typeof json === 'object' ? json.resourceType : null;
113
+ if (!TERMINOLOGY_TYPES.includes(rt)) {
114
+ if (this._skipInvalidFiles) continue;
115
+ throw new Error(`${fullPath} is not a CodeSystem, ValueSet or ConceptMap (resourceType = ${rt || 'none'})`);
116
+ }
75
117
 
76
118
  files.push({
77
- filename: entry.name,
119
+ filename: name,
78
120
  resourceType: rt,
79
121
  id: json.id,
80
122
  url: json.url,
@@ -88,4 +130,52 @@ class FolderContentLoader extends PackageContentLoader {
88
130
  }
89
131
  }
90
132
 
91
- module.exports = { FolderContentLoader };
133
+ /**
134
+ * A content loader for a single nominated JSON file holding one CodeSystem,
135
+ * ValueSet or ConceptMap. Everything else works exactly as for a folder source:
136
+ * the file's own directory is the read-only content root, and the provider
137
+ * caches live in the supplied cacheFolder.
138
+ *
139
+ * Unlike a folder scan, a bad file is an error rather than something to skip -
140
+ * the config named this file explicitly, so failing silently would just hide
141
+ * a typo.
142
+ */
143
+ class FileContentLoader extends FolderContentLoader {
144
+ /**
145
+ * @param {string} filePath - The JSON file to load
146
+ * @param {string} cacheFolder - Folder where Package*Provider DBs may live
147
+ */
148
+ constructor(filePath, cacheFolder) {
149
+ super(path.dirname(filePath), cacheFolder);
150
+ this.filePath = filePath;
151
+ this.fileName = path.basename(filePath);
152
+ }
153
+
154
+ _packageName() {
155
+ return 'file.' + this.fileName.replace(/\.json$/i, '');
156
+ }
157
+
158
+ get _skipInvalidFiles() {
159
+ return false;
160
+ }
161
+
162
+ async _validateSource() {
163
+ try {
164
+ const stat = await fs.stat(this.filePath);
165
+ if (!stat.isFile()) {
166
+ throw new Error(`File source path is not a file: ${this.filePath}`);
167
+ }
168
+ } catch (err) {
169
+ if (err.code === 'ENOENT') {
170
+ throw new Error(`File source path does not exist: ${this.filePath}`);
171
+ }
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ async _candidateFiles() {
177
+ return [this.fileName];
178
+ }
179
+ }
180
+
181
+ module.exports = { FolderContentLoader, FileContentLoader };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fhirsmith",
3
- "version": "0.11.1",
4
- "txVersion": "1.9.1",
3
+ "version": "0.12.0",
4
+ "txVersion": "1.9.3",
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": {
@@ -46,7 +46,7 @@
46
46
  "express-rate-limit": "^7.4.1",
47
47
  "express-session": "^1.19.0",
48
48
  "fast-xml-parser": "^5.3.4",
49
- "fhir-validator-wrapper": "1.2.2",
49
+ "fhir-validator-wrapper": "1.3.0",
50
50
  "fhirpath": "^4.8.3",
51
51
  "fs-extra": "^11.3.3",
52
52
  "ini": "^6.0.0",
@@ -1,6 +1,7 @@
1
1
  const express = require('express');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const validation = require('./validation');
4
5
  const Database = require('sqlite3').Database;
5
6
  const bcrypt = require('bcrypt');
6
7
  const session = require('express-session');
@@ -8,6 +9,10 @@ const folders = require('../library/folder-setup');
8
9
  const escape = require('escape-html');
9
10
  const {Utilities} = require("../library/utilities");
10
11
 
12
+ // GitHub refuses any file larger than this at the pre-receive hook, and the rejection takes down
13
+ // the whole push rather than just the offending file.
14
+ const GITHUB_MAX_FILE_SIZE = 100 * 1024 * 1024;
15
+
11
16
 
12
17
  class PublisherModule {
13
18
  constructor(stats) {
@@ -928,6 +933,137 @@ class PublisherModule {
928
933
  }
929
934
  }
930
935
 
936
+ // The IG Publisher can leave a file in the web folder that GitHub will not accept - typically a
937
+ // technical correction archive, which is a zip of an entire published version folder and so
938
+ // carries that folder's own full-ig.zip inside it. GitHub rejects the entire push over one such
939
+ // file, at the very last step of a publication run. The IG Publisher will keep these out of the
940
+ // web folder itself from the next release; until then, move them aside here.
941
+ //
942
+ // Does nothing unless publisher.large-file-archive says where to put them, and the website is
943
+ // one that's hosted on GitHub in the first place.
944
+ async archiveLargeFiles(task, website) {
945
+ const configured = this.config['large-file-archive'];
946
+ if (!configured || !this.isGitHubHosted(website)) {
947
+ return;
948
+ }
949
+ const root = website.git_root || website.local_folder;
950
+ const oversized = this.findLargeFiles(root, GITHUB_MAX_FILE_SIZE);
951
+ if (oversized.length === 0) {
952
+ return;
953
+ }
954
+
955
+ const archiveDir = path.isAbsolute(configured)
956
+ ? configured
957
+ : folders.filePath('publisher', configured);
958
+ if (!fs.existsSync(archiveDir)) {
959
+ fs.mkdirSync(archiveDir, { recursive: true });
960
+ }
961
+
962
+ for (const file of oversized) {
963
+ const sizeMB = Math.round(fs.statSync(file).size / (1024 * 1024));
964
+ const dest = this.nonClashingPath(archiveDir, path.basename(file));
965
+ this.moveFile(file, dest);
966
+ await this.logTaskMessage(task.id, 'warn', 'Large file ' + path.relative(root, file) + ' (' + sizeMB +
967
+ ' MB) is over the 100 MB GitHub limit - moved to ' + dest + ' instead of being published');
968
+ }
969
+ }
970
+
971
+ // Only a website kept in a GitHub repository has a 100MB problem - others can carry files of any
972
+ // size. $.website.github in the web root's publish-setup.json says which is which; if that can't
973
+ // be read, or doesn't say, assume not and leave the files where they are.
974
+ isGitHubHosted(website) {
975
+ if (!website.local_folder) {
976
+ return false;
977
+ }
978
+ const setupPath = path.join(website.local_folder, 'publish-setup.json');
979
+ try {
980
+ const setup = JSON.parse(fs.readFileSync(setupPath, 'utf8'));
981
+ return !!(setup.website && setup.website.github === true);
982
+ } catch (err) {
983
+ this.logger.warn('Could not read ' + setupPath + ' to check whether the website is on GitHub: ' + err.message);
984
+ return false;
985
+ }
986
+ }
987
+
988
+ // Files in the web folder that are too big to push. Never descends into .git: a pack file over the
989
+ // limit is perfectly normal there, and moving it would destroy the repository. Files git is already
990
+ // ignoring are left alone, since they were never going to be pushed anyway.
991
+ findLargeFiles(root, limit) {
992
+ const found = [];
993
+ const walk = (dir) => {
994
+ let entries;
995
+ try {
996
+ entries = fs.readdirSync(dir, { withFileTypes: true });
997
+ } catch (err) {
998
+ this.logger.warn('Large file scan could not read ' + dir + ': ' + err.message);
999
+ return;
1000
+ }
1001
+ for (const entry of entries) {
1002
+ const full = path.join(dir, entry.name);
1003
+ if (entry.isDirectory()) {
1004
+ if (entry.name !== '.git') {
1005
+ walk(full);
1006
+ }
1007
+ } else if (entry.isFile()) {
1008
+ try {
1009
+ if (fs.statSync(full).size > limit) {
1010
+ found.push(full);
1011
+ }
1012
+ } catch (err) {
1013
+ // the file went away between readdir and stat - nothing left to move
1014
+ }
1015
+ }
1016
+ }
1017
+ };
1018
+ walk(root);
1019
+ return found.length === 0 ? found : this.removeGitIgnored(root, found);
1020
+ }
1021
+
1022
+ removeGitIgnored(root, files) {
1023
+ try {
1024
+ const result = require('child_process').spawnSync('git', ['check-ignore', '--stdin'],
1025
+ { cwd: root, input: files.join('\n'), encoding: 'utf8' });
1026
+ // exit 0: at least one path is ignored. 1: none are. anything else: git could not tell us,
1027
+ // in which case treat everything as pushable rather than leaving a file that breaks the push.
1028
+ if (result.status !== 0 || !result.stdout) {
1029
+ return files;
1030
+ }
1031
+ const ignored = new Set(result.stdout.split('\n').map((s) => s.trim()).filter((s) => s));
1032
+ return files.filter((f) => !ignored.has(f));
1033
+ } catch (err) {
1034
+ this.logger.warn('Could not check the gitignore status of large files: ' + err.message);
1035
+ return files;
1036
+ }
1037
+ }
1038
+
1039
+ nonClashingPath(dir, name) {
1040
+ let candidate = path.join(dir, name);
1041
+ if (!fs.existsSync(candidate)) {
1042
+ return candidate;
1043
+ }
1044
+ const ext = path.extname(name);
1045
+ const stem = path.basename(name, ext);
1046
+ let i = 2;
1047
+ do {
1048
+ candidate = path.join(dir, stem + '-' + i + ext);
1049
+ i++;
1050
+ } while (fs.existsSync(candidate));
1051
+ return candidate;
1052
+ }
1053
+
1054
+ // rename() fails across filesystems, and the archive folder will not always be on the same one
1055
+ moveFile(src, dest) {
1056
+ try {
1057
+ fs.renameSync(src, dest);
1058
+ } catch (err) {
1059
+ if (err.code !== 'EXDEV') {
1060
+ throw err;
1061
+ }
1062
+ fs.copyFileSync(src, dest);
1063
+ fs.unlinkSync(src);
1064
+ }
1065
+ }
1066
+
931
1067
  async runPublication(task) {
932
1068
  const website = await this.getWebsite(task.website_id);
933
1069
  if (!website) {
@@ -1016,6 +1152,9 @@ class PublisherModule {
1016
1152
  // url - can survive into the web tree. Catch that here, before anything is committed.
1017
1153
  await this.verifyPublishedPackage(task, website, draftDir);
1018
1154
 
1155
+ // Step 5c: Move anything too big for GitHub out of the web folder, before it gets staged
1156
+ await this.archiveLargeFiles(task, website);
1157
+
1019
1158
  // Step 6: Commit and push the web folder
1020
1159
  await this.logTaskMessage(task.id, 'info', 'Committing changes to web folder...');
1021
1160
  const gitUrl = 'https://github.com/' + task.github_org + '/' + task.github_repo + '.git';
@@ -1363,23 +1502,23 @@ class PublisherModule {
1363
1502
  content += '</div>';
1364
1503
  content += '<div class="col-md-3">';
1365
1504
  content += '<label for="github_org" class="form-label">GitHub Org</label>';
1366
- content += '<input type="text" class="form-control" id="github_org" name="github_org" required placeholder="hl7">';
1505
+ content += '<input type="text" class="form-control" id="github_org" name="github_org" required maxlength="39" pattern="' + validation.HTML_PATTERNS.github_org + '" title="Letters, digits and single hyphens" placeholder="hl7">';
1367
1506
  content += '</div>';
1368
1507
  content += '<div class="col-md-3">';
1369
1508
  content += '<label for="github_repo" class="form-label">GitHub Repo</label>';
1370
- content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required placeholder="fhir-us-core">';
1509
+ content += '<input type="text" class="form-control" id="github_repo" name="github_repo" required maxlength="100" pattern="' + validation.HTML_PATTERNS.github_repo + '" title="Letters, digits, dots, hyphens and underscores" placeholder="fhir-us-core">';
1371
1510
  content += '</div>';
1372
1511
  content += '<div class="col-md-3">';
1373
1512
  content += '<label for="git_branch" class="form-label">Branch</label>';
1374
- content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required placeholder="main">';
1513
+ content += '<input type="text" class="form-control" id="git_branch" name="git_branch" required maxlength="255" pattern="' + validation.HTML_PATTERNS.git_branch + '" title="A git branch name - slashes are fine, but not spaces, backslashes or any of ~ ^ : ? * [" placeholder="main">';
1375
1514
  content += '</div>';
1376
1515
  content += '<div class="col-md-4">';
1377
1516
  content += '<label for="npm_package_id" class="form-label">NPM Package ID</label>';
1378
- content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required placeholder="hl7.fhir.us.core">';
1517
+ content += '<input type="text" class="form-control" id="npm_package_id" name="npm_package_id" required maxlength="128" pattern="' + validation.HTML_PATTERNS.npm_package_id + '" title="Letters, digits, dots, hyphens and underscores" placeholder="hl7.fhir.us.core">';
1379
1518
  content += '</div>';
1380
1519
  content += '<div class="col-md-4">';
1381
1520
  content += '<label for="version" class="form-label">Version</label>';
1382
- content += '<input type="text" class="form-control" id="version" name="version" required placeholder="6.0.0">';
1521
+ content += '<input type="text" class="form-control" id="version" name="version" required maxlength="64" pattern="' + validation.HTML_PATTERNS.version + '" title="Letters, digits, dots, hyphens and plus signs" placeholder="6.0.0">';
1383
1522
  content += '</div>';
1384
1523
  content += '<div class="col-md-4 d-flex align-items-end">';
1385
1524
  content += '<button type="submit" class="btn btn-primary">Create Task</button>';
@@ -1495,6 +1634,15 @@ class PublisherModule {
1495
1634
  try {
1496
1635
  const {website_id, github_org, github_repo, git_branch, npm_package_id, version} = req.body;
1497
1636
 
1637
+ // Check the input before it goes anywhere near a git command line or a
1638
+ // file name. The browser checks these too (see HTML_PATTERNS), but the
1639
+ // form isn't the only way to reach this route.
1640
+ const problems = validation.validateTaskInput(req.body);
1641
+ if (problems.length > 0) {
1642
+ this.logger.warn('Rejected task creation from user ' + req.session.userId + ': ' + problems.join('; '));
1643
+ return res.status(400).send('Invalid task details: ' + problems.join('; '));
1644
+ }
1645
+
1498
1646
  // Verify user has permission for this website
1499
1647
  const canQueue = await this.userCanQueue(req.session.userId, website_id);
1500
1648
  if (!canQueue) {