diff --git a/.cspell.json b/.cspell.json index f8710f7fc7..afd099a4e4 100644 --- a/.cspell.json +++ b/.cspell.json @@ -489,7 +489,15 @@ "requestfinished", "LOCF", "Unack", - "Tabnabbing" + "Tabnabbing", + "STIG", + "DISA", + "ISSM", + "remediations", + "rethrew", + "notexample", + "Deployers", + "stig" ], "dictionaries": ["npm", "softwareTerms", "node", "html", "css", "bash", "en_US", "en-gb", "misc"], "ignorePaths": [ diff --git a/API.md b/API.md index efeb4a2be2..ae2ec3de3a 100644 --- a/API.md +++ b/API.md @@ -1365,6 +1365,68 @@ can be used to manage user information and roles. Open MCT provides an example [user](example/exampleUser/exampleUserCreator.js) and [user provider](example/exampleUser/ExampleUserProvider.js) which can be used as a starting point for creating a custom user provider. +## Audit API + +`openmct.audit` emits structured audit records (who / what / when / outcome) for +operator actions that already carry a user context: import and export as JSON, +role changes, notebook entry creation and deletion, and fault acknowledgement +and shelving. Records are kept in memory only; nothing is persisted or +transmitted unless a provider is registered. + +Each record has the shape: + +```javascript +{ + id: string, // unique id for this record + source: 'openmct', + timestamp: string, // ISO 8601 UTC + action: string, // e.g. 'import', 'notebook.entry.create', 'user.role.change' + outcome: 'success' | 'failure', + actor: { id: string | null, username: string | null, role: string | null }, + target: string | null, // key string of the domain object acted upon, if any + details: Object // action-specific context; never contains raw errors +} +``` + +`outcome` describes the operator action as carried out by the application. For +actions that persist through `openmct.objects.mutate` (notebook entries), the +write is queued in the active transaction or saved asynchronously, so a later +provider failure is reported through the persistence error path rather than by +rewriting the audit record. Import and export await their writes and report +`'failure'` when a write is rejected. + +Providers receive every completed record and may return a promise. A provider +that throws or rejects is logged and does not affect the originating action or +other providers: + +```javascript +openmct.audit.addProvider({ + record(auditRecord) { + return fetch('/audit', { method: 'POST', body: JSON.stringify(auditRecord) }); + } +}); +openmct.audit.removeProvider(provider); +openmct.audit.hasProviders(); // boolean +``` + +Plugins may also emit their own records; `outcome` defaults to `'success'` and +`target` accepts an identifier or key string. The returned promise resolves with +the dispatched record once every provider has settled (accepted or rejected it), +so awaiting it gives confirmed delivery; built-in hooks do not await it so that +audit delivery never delays the operator action: + +```javascript +await openmct.audit.record({ + action: 'my-plugin.publish', + outcome: 'failure', + target: domainObject.identifier, + details: { reason: 'Timeout' } +}); +``` + +`openmct.audit` is an `EventEmitter`; `openmct.audit.on('record', listener)` +(and `once` / `off`) observe records in-process with standard emitter semantics. + ## Visibility-Based Rendering in View Providers To enhance performance and resource efficiency in OpenMCT, a visibility-based rendering feature has been added. This feature is designed to defer the execution of rendering logic for views that are not currently visible. It ensures that views are only updated when they are in the viewport, similar to how modern browsers handle rendering of inactive tabs but optimized for the OpenMCT tabbed display. It also works when views are scrolled outside the viewport (e.g., in a Display Layout). diff --git a/docs/security/asd-stig-nist-800-53-review.md b/docs/security/asd-stig-nist-800-53-review.md new file mode 100644 index 0000000000..777cc00f0e --- /dev/null +++ b/docs/security/asd-stig-nist-800-53-review.md @@ -0,0 +1,200 @@ +# ASD STIG and NIST SP 800-53 Rev. 5 review of Open MCT untrusted-input boundaries + +This document records a source-code review of Open MCT's untrusted-input and +data boundaries against the DISA Application Security and Development (ASD) +STIG and NIST SP 800-53 Rev. 5. It lists findings, an explicit outcome for +every boundary, what was remediated in the accompanying change set, and the +deployment evidence that cannot be determined from source code alone. + +This is a code review, not a certification, accreditation, or attestation of +compliance. Outcomes marked `satisfied` mean the reviewed source implements the +control's intent as far as it can be determined from the code; they are inputs +to an assessor's determination, not a determination themselves. + +## Scope and method + +- Repository state reviewed: `master` at `a4aae41af`, with remediations on + branch `devin/1788814351-asd-stig-nist-review-remediation`. +- Method: repository-wide searches for parsers, HTML/DOM sinks (`v-html`, + `innerHTML`), URL sinks, persistence providers, user/role providers, + expression evaluators and operator-facing error paths, followed by manual + reading of each boundary. +- ASD STIG source: DISA Application Security and Development STIG, Version 6 + Release 4 (V6R4), dated 2025-09-09, as published at + + (34 CAT I, 230 CAT II, 22 CAT III rules). Rule IDs and titles below were + read from the per-rule pages of that publication. +- NIST SP 800-53 Rev. 5 control identifiers are the ones named in the ASD STIG + rule text and in the organization's control vocabulary. +- Line numbers refer to the remediated branch. + +### Outcome vocabulary + +| Outcome | Meaning | +| --- | --- | +| `satisfied` | The reviewed source implements the control's intent at this boundary. | +| `not-satisfied` | A weakness was found in source. All such rows in this document are remediated in the accompanying change set unless stated otherwise. | +| `needs-input` | Cannot be determined from source; requires deployment evidence from the ISSM / assessor / system owner. | +| `not-applicable` | The control does not apply to this boundary as implemented. | + +### Relationship to open security changes #10-#13 + +The following findings were already addressed by open pull requests on this +fork and are intentionally **not** duplicated here: + +| PR | Boundary | Finding covered there | +| --- | --- | --- | +| #10 | Comps plugin SharedWorker | Hardening of `mathjs` expression evaluation in the Comps worker. | +| #11 | Web Page plugin | `iframe` sandboxing and URL allowlist for embedded web pages. | +| #12 | Build / supply chain | npm registry signature verification. | +| #13 | Export as CSV | Formula-injection protection in CSV export. | + +This review therefore excludes `src/plugins/comps`, `src/plugins/webPage` and +the CSV exporter; the Export-as-JSON row below covers only the JSON path. + +## Trust-boundary summary + +Every boundary named in the review request has an explicit outcome. + +| # | Boundary | Primary location | Outcome | Remediated here | +| --- | --- | --- | --- | --- | +| B1 | Import from JSON | `src/plugins/importFromJSONAction/` | `not-satisfied` -> remediated | Yes (F-01, F-02, F-03, F-13) | +| B2 | Export as JSON | `src/plugins/exportAsJSONAction/` | `not-satisfied` -> remediated | Yes (F-04, F-13) | +| B3 | Notebook entry text (Markdown) | `src/plugins/notebook/components/NotebookEntry.vue` | `not-satisfied` -> remediated | Yes (F-05, F-06, F-13) | +| B4 | Notebook embeds / snapshot images | `src/plugins/notebook/components/NotebookEmbed.vue`, `utils/notebook-image.js` | `satisfied` | No change required | +| B5 | Hyperlink plugin | `src/plugins/hyperlink/HyperlinkLayout.vue` | `satisfied` | No change required | +| B6 | URL indicator plugin | `src/plugins/URLIndicatorPlugin/URLIndicator.js` | `needs-input` | No (F-14) | +| B7 | CouchDB persistence | `src/plugins/persistence/couch/` | `not-satisfied` (SI-11) -> remediated; `needs-input` (SC-8, IA-2, SC-28) | Yes (F-07); deployment items open (F-15, F-16, F-18) | +| B8 | LocalStorage persistence | `src/plugins/localStorage/`, `src/api/user/StoragePersistence.js` | `not-satisfied` (SI-11) -> remediated; `needs-input` (SC-28) | Yes (F-08); deployment item open (F-18) | +| B9 | Example user / operator status / role model | `example/exampleUser/`, `src/plugins/userIndicator/`, `src/plugins/operatorStatus/`, `src/api/user/` | `needs-input` (IA-2, AC-3, session); `not-satisfied` (AU-2/AU-3) -> remediated | Yes (F-13 role-change audit); deployment items open (F-17, F-19) | +| B10 | Condition Sets (user-authored criteria) | `src/plugins/condition/utils/operations.js` | `satisfied` | No change required | +| B11 | Summary Widgets (user-authored rules) | `src/plugins/summaryWidget/src/ConditionEvaluator.js` | `satisfied` | No change required | +| B12 | `v-html` sinks | `NotebookEntry.vue`, `TextHighlight.vue`, `AboutDialog.vue` | `not-satisfied` -> remediated (two sinks); `satisfied` (one sink, config-only) | Yes (F-05, F-09) | +| B13 | `innerHTML` sinks | 15 assignment sites (listed under F-10) | `satisfied` | No change required | +| B14 | Fault management acknowledge / shelve | `src/api/faultmanagement/FaultManagementAPI.js` | `not-satisfied` (AU-2/AU-3) -> remediated | Yes (F-13) | +| B15 | Object create / save operator-facing errors | `src/plugins/formActions/CreateAction.js` | `not-satisfied` -> remediated | Yes (F-11) | +| B16 | Deployment: TLS, authentication, session, storage encryption, security headers, centralized logging | Not in source | `needs-input` | No (F-15 to F-20) | + +## Findings + +Severity is the ASD STIG category of the cited rule. "Org ref" is the +organization control-vocabulary identifier (STIG V-2206xx <-> NIST mapping) +used across this organization's repositories. + +| ID | File:line | Description | CWE | ASD STIG V6R4 rule | NIST 800-53 r5 | Severity | Org ref | Outcome | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| F-01 | `src/plugins/importFromJSONAction/ImportFromJSONAction.js:87` (before: `JSON.parse` of file contents with no schema check) | Imported JSON was parsed and persisted with only a `__proto__` filter; `constructor`/`prototype` keys, mismatched identifier/key-strings, non-string types, malformed composition and condition-set references and unknown root IDs were accepted. | CWE-1321, CWE-20 | V-222606 The application must validate all input. | SI-10 | CAT II | V-220631 | `not-satisfied` -> remediated: `importValidation.js` rejects reserved keys at any depth (bounded to 64 nesting levels / 20 reported errors), and validates root, identifiers, types, names, locations, composition and condition-set reference *syntax* before any `save()`. Composition references to objects absent from the payload are deliberately accepted because `ExportAsJSONAction#exportObject` omits non-creatable children (e.g. read-only telemetry) while leaving them in the parent's `composition`; such references resolve to a "Missing" placeholder at runtime and are never dereferenced as code. Residual: no cap is imposed on total object count or file size because legitimate operational exports vary widely; the ISSM should size such a limit for the deployment if required. | +| F-02 | `src/plugins/importFromJSONAction/ImportFromJSONAction.js:106,452` | Import failures surfaced provider/parser text to the operator. | CWE-209 | V-222610 Error messages must not reveal exploitable information. | SI-11(a) | CAT II | V-220641 | `not-satisfied` -> remediated: generic `IMPORT_REJECTED_MESSAGE` / `SAVE_FAILED_MESSAGE`; diagnostics only in `console.error`. | +| F-03 | `src/plugins/importFromJSONAction/ImportFromJSONAction.js:348-350` | Partial persistence: objects were saved as the tree was walked, so a later validation failure left earlier objects persisted. | CWE-20 | V-222609 Not subject to input handling vulnerabilities. | SI-10(3) | CAT I | V-220631 | `not-satisfied` -> remediated: validation completes before the first `save()`; spec asserts zero `save` calls on invalid trees. Residual: the object API has no delete or cross-object transaction, so when one save of an already-validated tree fails at the provider, sibling objects that did save stay in storage. They are never linked into the target composition; their key strings are written to the console diagnostic and to the failure audit record (`details.unlinkedKeys`) so an administrator can remove them at the provider. | +| F-04 | `src/plugins/exportAsJSONAction/ExportAsJSONAction.js:102-107` | Export failure path rethrew raw errors to the UI. | CWE-209 | V-222610 | SI-11(a) | CAT II | V-220641 | `not-satisfied` -> remediated: generic notification, raw error logged, failure audit record. | +| F-05 | `src/plugins/notebook/components/NotebookEntry.vue:460-461` | Notebook Markdown was rendered through `marked` and `sanitize-html`, but the sanitizer schema and the custom link renderer allowed `data:`/protocol-relative links, suffix-matching of the hostname allowlist (`notexample.com` passed for `example.com`) and unescaped link text/URL in hand-built anchor markup. | CWE-79, CWE-20 | V-222602 Protect from Cross-Site Scripting (XSS). | SI-10 | CAT I | V-220632 | `not-satisfied` -> remediated: schema restricted to `http`/`https`/`mailto`, no protocol-relative URLs, allowed attributes enumerated; link renderer (`:211`, `:471-479`) requires `http(s):`, exact-host or dot-delimited subdomain match, and escapes text and `href`. | +| F-06 | `src/plugins/notebook/components/NotebookEntry.vue:571`, `NotebookComponent.vue:676-677`, `src/plugins/notebook/utils/notebook-image.js:72` | Image-drop failures interpolated the raw error into the operator notification. | CWE-209 | V-222610 | SI-11(a) | CAT II | V-220641 | `not-satisfied` -> remediated: `'Unable to add image.'` shown; error logged. | +| F-07 | `src/plugins/persistence/couch/CouchObjectProvider.js:233-235, 253-255, 278-285` | CouchDB network failures, HTTP error bodies and malformed responses propagated raw `fetch`/JSON error text (including server URL and CouchDB `reason` strings) to callers that display it. | CWE-209, CWE-755 | V-222610; V-222656 Not subject to error handling vulnerabilities. | SI-11(a), SA-15(5) | CAT II | V-220641 | `not-satisfied` -> remediated: normalized to `openmct.objects.errors.Persistence` (`src/api/objects/PersistenceError.js`) with a generic message; `Conflict` preserved; raw detail in `console.error`. | +| F-08 | `src/plugins/localStorage/LocalStorageObjectProvider.js:37, 47, 67, 89, 102-105` | `localStorage` access, quota and `JSON.parse` failures threw raw browser exceptions to callers. | CWE-209, CWE-755 | V-222610; V-222656 | SI-11(a), SA-15(5) | CAT II | V-220641 | `not-satisfied` -> remediated: normalized to `PersistenceError`; raw detail logged. | +| F-09 | `src/utils/textHighlight/TextHighlight.vue:24, 63-65` | Search-highlight component built HTML by string replacement with the user-supplied highlight term inserted into the markup and used as an unescaped regular expression. | CWE-79, CWE-1333 | V-222602 | SI-10 | CAT I | V-220632 | `not-satisfied` -> remediated: text and term HTML-escaped, term regex-escaped, class attribute escaped, match reinserted via `$1`. | +| F-10 | `innerHTML` sites: `src/ui/inspector/InspectorViews.vue:63`, `src/ui/components/ObjectView.vue:171`, `src/ui/preview/PreviewContainer.vue:115`, `src/plugins/inspectorViews/styles/SavedStylesInspectorView.vue:48,56`, `src/plugins/summaryWidget/src/SummaryWidget.js:209`, `.../TestDataItem.js:177`, `.../Condition.js:197`, `.../input/Select.js:88`, `src/plugins/notebook/components/NotebookSnapshotIndicator.vue:100`, `src/plugins/imagery/components/ImageryTimeView.vue:329`, `src/plugins/performanceIndicator/plugin.js:88`, `src/plugins/plot/chart/MctChart.vue:561` | Each site assigns an empty string or a static developer-authored template literal with no interpolated user data. `NotebookEntry.vue:318` returns sanitized output from F-05. | CWE-79 | V-222602 | SI-10 | CAT I | V-220632 | `satisfied` (no untrusted data reaches these sinks). Reviewer note: `MctChart.vue:561` and `performanceIndicator/plugin.js:88` interpolate only constants; re-review if they are ever given object-derived values. | +| F-11 | `src/plugins/formActions/CreateAction.js:94` | Object-creation failure interpolated the raw error object into the operator notification (`Error saving objects: ${err}`). Same pattern as F-02/F-04/F-06; `EditPropertiesAction.js:76` and `BrowseBar.vue:440` already used the generic form. | CWE-209 | V-222610 | SI-11(a) | CAT II | V-220641 | `not-satisfied` -> remediated: generic message, raw error logged. | +| F-12 | `src/ui/layout/AboutDialog.vue:30` | `v-html="branding.aboutHtml"`: content is the deployer's static `Branding` configuration (`src/api/Branding.js:29`), not end-user data. | CWE-79 | V-222602 | SI-10 | CAT I | V-220632 | `satisfied` (configuration-only sink). Deployers must treat `aboutHtml` as trusted markup. | +| F-13 | `src/api/user/UserAPI.js:135`, `src/api/faultmanagement/FaultManagementAPI.js:122-171`, `src/plugins/importFromJSONAction/ImportFromJSONAction.js:107,350,366`, `src/plugins/exportAsJSONAction/ExportAsJSONAction.js:107,391`, `src/plugins/notebook/utils/notebook-entries.js:242,266,335,356`, `src/plugins/notebook/components/NotebookComponent.vue:586` | No audit records were produced for operator actions that already carry a user context (import, export, role change, notebook entry create/delete, fault acknowledge/shelve). | CWE-778 | V-222471 Log user actions involving access to data; V-222472 Log user actions involving changes to data; V-222476 Audit records establish outcome; V-222477 Audit records establish identity. | AU-2, AU-3, AU-12 | CAT II | V-220635 | `not-satisfied` -> remediated: `src/api/audit/AuditLogger.js` registered as `openmct.audit` (`src/MCT.js:205`) emits `{id, source, timestamp, action, outcome, actor{id,username,role}, target, details}` to in-process subscribers; hooks at the listed sites, including policy-rejected imports (`ImportFromJSONAction.js` composition-policy branch). Role changes mirrored from another browsing context (`ActiveRoleSynchronizer`) are not re-recorded, so one selection yields one record. Import and export await their writes and record `failure` when a write is rejected; notebook hooks record the operator action at mutation time because `ObjectAPI.mutate()` queues the write in the active transaction or saves asynchronously and exposes no persistence result, so a later provider failure is reported through the persistence error path (F-06/F-07) rather than by rewriting the audit record. Public contract documented in `API.md` ("Audit API"). No external sink is configured (see F-20). | +| F-14 | `src/plugins/URLIndicatorPlugin/URLIndicator.js:78,100` | The indicator polls a deployer-configured URL with `fetch()` and reports reachability only; the response body is not rendered. Whether the configured URL uses TLS is a deployment setting. | CWE-319 | V-222596 Protect confidentiality and integrity of transmitted information. | SC-8 | CAT I | V-220634 | `needs-input` (URL scheme is configuration). Input handling: `satisfied` (no body rendering). | +| F-15 | `src/plugins/persistence/couch/plugin.js:36-68`, `CouchObjectProvider.js:39,218,464` | CouchDB base URL is deployer configuration; the client uses `fetch()` and inherits browser cookies. TLS termination and TLS version policy are not visible in source. | CWE-319 | V-222596; V-222597 Cryptographic mechanisms during transmission. | SC-8, SC-8(1), SC-13 | CAT I | V-220634 | `needs-input`. | +| F-16 | `src/plugins/persistence/couch/CouchObjectProvider.js:218` | No credentials are embedded in source (`satisfied` for V-222642 / IA-5(7)). Authentication to CouchDB (proxy auth, cookie session, IdP) is external to this code. | CWE-306 | V-222522 Uniquely identify and authenticate organizational users; V-222642 No embedded authentication data. | IA-2, IA-5(7), AC-3 | CAT I | V-220629 | `needs-input` for IA-2/AC-3; `satisfied` for IA-5(7). | +| F-17 | `example/exampleUser/ExampleUserProvider.js:96-117, 228-240`, `src/api/user/UserAPI.js`, `src/plugins/userIndicator/components/UserIndicator.vue:158-160`, `src/plugins/operatorStatus/` | The bundled user provider is an example in-memory provider with an auto-login path; it is not an authentication mechanism and must not be deployed as one. The `UserAPI` provider interface carries identity, roles and status but does not itself enforce authorization on object operations. Poll-question and status inputs (`PollQuestion.vue:69`) are rendered with `{{ }}` text interpolation (`satisfied` for XSS). | CWE-287, CWE-285 | V-222522; V-222425 Enforce approved authorizations; V-222556 Non-organizational users. | IA-2, AC-3, AC-6 | CAT I | V-220629 | `needs-input`: production `UserProvider` implementation and the authorization model enforced by the persistence tier. Example provider: `not-applicable` for production evidence. | +| F-18 | `src/plugins/localStorage/LocalStorageObjectProvider.js`, `src/api/user/StoragePersistence.js:27-33` | Domain objects (LocalStorage provider) and the active role are stored unencrypted in browser `localStorage`; CouchDB at-rest protection is server-side. | CWE-312 | V-222587 Protect confidentiality and integrity of stored information; V-222588 Approved cryptographic mechanisms for information at rest. | SC-28, SC-28(1) | CAT II / CAT I | V-220633 | `needs-input`: data-sensitivity determination for browser-stored objects and CouchDB/host encryption evidence. | +| F-19 | Not in source (browser session, reverse proxy, IdP) | Session identifiers, inactivity timeout, logoff, cookie flags and concurrent-session limits are provided by the deployment (proxy/IdP), not by this client. | CWE-613, CWE-614 | V-222577 Do not expose session IDs; V-222389 15-minute idle termination; V-222391 Logoff capability; V-222388 Clear temporary storage and cookies on termination; V-222387 Limit logon sessions. | AC-7, AC-12, SC-23 | CAT I / CAT II | V-220630 | `needs-input`. | +| F-20 | `src/api/audit/AuditLogger.js` (in-process only) | Audit records are delivered to in-process subscribers; forwarding to a protected, centralized log store with a unique application identifier is a deployment integration. | CWE-778 | V-222475 Unique identifier when using centralized logging. | AU-4, AU-6, AU-9 | CAT II | V-220635 | `needs-input`: a provider that ships records to the site logging system, and evidence of retention/protection. | +| F-21 | `src/plugins/condition/utils/operations.js:45`, `src/plugins/summaryWidget/src/ConditionEvaluator.js:344-413` | User-authored Condition Set criteria and Summary Widget rules are evaluated by looking up a named operation in a fixed table of functions; no `eval`, `new Function` or template compilation of user text was found in `src/` or `example/`. | CWE-94, CWE-95 | V-222609; V-222604 Protect from command injection. | SI-10, CM-7 | CAT I | V-220631 | `satisfied` (Comps `mathjs` evaluation is covered by #10 and excluded here). | +| F-22 | `src/plugins/hyperlink/HyperlinkLayout.vue:37,50` | Hyperlink `href` is passed through `@braintree/sanitize-url`, which blocks `javascript:`, `data:` and other non-navigational schemes. | CWE-79 | V-222602 | SI-10 | CAT I | V-220632 | `satisfied`. | +| F-23 | `src/plugins/notebook/components/NotebookEmbed.vue:31`, `src/plugins/notebook/utils/notebook-image.js:8-20,60` | Snapshot thumbnails and full-size images are bound with `:src` (attribute binding, not markup) and originate from canvas captures / object-URLs created by the application; entry embeds render object names via text interpolation. | CWE-79 | V-222602 | SI-10 | CAT I | V-220632 | `satisfied`. | +| F-24 | `src/plugins/notebook/components/NotebookEntry.vue:684` | Entry text saved from the editor is stripped to plain text with `sanitize-html` (`allowedTags: []`) before storage. | CWE-79 | V-222606 | SI-10 | CAT II | V-220631 | `satisfied`. | +| F-25 | `src/api/objects/ObjectAPI.js:441` | Conflict notification interpolates the object key-string (application identifier, not error internals). | CWE-209 | V-222600 Do not disclose unnecessary information. | SI-11 | CAT II | V-220641 | `satisfied` (identifier only, no internal detail). | + +### Counts by outcome + +| Outcome | Count | Findings | +| --- | --- | --- | +| `not-satisfied` -> remediated in this change set | 11 | F-01, F-02, F-03, F-04, F-05, F-06, F-07, F-08, F-09, F-11, F-13 | +| `satisfied` | 7 | F-10, F-12, F-21, F-22, F-23, F-24, F-25 | +| `needs-input` | 7 | F-14, F-15, F-16, F-17, F-18, F-19, F-20 | +| `not-applicable` | 1 | F-17 (example user provider as production authentication evidence) | +| `not-satisfied` left open | 0 | - | + +## Remediated in this change set + +| Remediation | Files | Karma specs | +| --- | --- | --- | +| R1 Import schema validation and prototype-pollution protection (F-01, F-02, F-03) | `src/plugins/importFromJSONAction/importValidation.js`, `ImportFromJSONAction.js` | `importValidationSpec.js`, `ImportFromJSONActionSpec.js` | +| R2 Notebook and `v-html` XSS hardening (F-05, F-06, F-09) | `src/plugins/notebook/components/NotebookEntry.vue`, `NotebookComponent.vue`, `src/utils/textHighlight/TextHighlight.vue` | `NotebookEntrySpec.js`, `TextHighlightSpec.js` | +| R3 Generic persistence error handling (F-04, F-07, F-08, F-11) | `src/api/objects/PersistenceError.js`, `ObjectAPI.js`, `src/plugins/persistence/couch/CouchObjectProvider.js`, `src/plugins/localStorage/LocalStorageObjectProvider.js`, `src/plugins/exportAsJSONAction/ExportAsJSONAction.js`, `src/plugins/formActions/CreateAction.js` | `couch/pluginSpec.js`, `localStorage/pluginSpec.js`, `ExportAsJSONActionSpec.js`, `CreateActionSpec.js` | +| R4 Structured audit-event hook (F-13) | `src/api/audit/AuditLogger.js`, `src/MCT.js`, `src/api/user/UserAPI.js`, `src/api/faultmanagement/FaultManagementAPI.js`, import/export actions, `src/plugins/notebook/utils/notebook-entries.js`, `NotebookComponent.vue` | `AuditLoggerSpec.js`, `UserAPISpec.js`, `FaultManagementAPISpec.js`, `notebook-entriesSpec.js`, import/export specs | + +Root-cause notes: + +- The raw-error-to-operator pattern (F-02, F-04, F-06, F-07, F-08, F-11) was + distributed across six sites with no central rule. R3 centralizes provider + failures in `PersistenceError` so that any caller that displays + `error.message` from the object API now shows generic text; the remaining + UI-level sites were changed individually. New code that displays an error to + the operator should show fixed text and pass the error object to + `console.error`. +- Audit hooks (R4) are placed in the API layer (`UserAPI`, + `FaultManagementAPI`) where possible so that any UI that calls those APIs is + covered; Notebook and import/export hooks live in the actions because there + is no shared API layer for those operations. + +### Audit record shape + +```json +{ + "id": "uuid", + "source": "openmct", + "timestamp": "2026-09-07T20:00:00.000Z", + "action": "fault.acknowledge", + "outcome": "success", + "actor": { "id": "user-id", "username": "operator", "role": "flight" }, + "target": { "type": "fault", "id": "..." }, + "details": {} +} +``` + +Providers subscribe with `openmct.audit.addProvider({ record(auditRecord) {} })`. +Actions recorded: `import`, `export`, `user.role.change`, +`notebook.entry.create`, `notebook.entry.delete`, `fault.acknowledge`, +`fault.shelve`. + +## Documented / needs-input + +An ISSM or assessor would need to supply the following to close the +`needs-input` rows. None of these can be inferred from this repository. + +| Item | Closes | Evidence requested | +| --- | --- | --- | +| CouchDB transport | F-15 | Reverse-proxy or CouchDB TLS configuration showing TLS 1.2+ only, cipher policy, certificate chain, and that the configured `url` is `https://`. | +| URL indicator target | F-14 | The configured indicator URL and its TLS posture. | +| CouchDB authentication and authorization | F-16, F-17 | How browser requests to CouchDB are authenticated (proxy authentication, IdP/OIDC, CouchDB cookie sessions), and the CouchDB `_security` / per-database role model that enforces AC-3 for read and write. | +| Production `UserProvider` | F-17 | The deployed `UserProvider` implementation, its IdP integration, MFA policy, and role source. The bundled `example/exampleUser` provider is not production evidence. | +| Session policy | F-19 | Proxy/IdP settings for idle timeout (15 min non-privileged / 10 min admin), logoff, concurrent-session limit, and cookie flags (`Secure`, `HttpOnly`, `SameSite`). | +| Storage encryption | F-18 | Data-sensitivity determination for objects held in browser `localStorage`; CouchDB host disk / volume encryption evidence. | +| Security headers | F-05, F-09 (defense in depth) | Reverse-proxy response headers: `Content-Security-Policy`, `Strict-Transport-Security`, `X-Content-Type-Options`, `X-Frame-Options` / `frame-ancestors`. Not set by this client. | +| Centralized audit logging | F-20 | An `openmct.audit` provider that forwards records to the site log store, the unique application identifier used, and retention/protection settings. | + +## Verification evidence + +Commands run on the remediated branch (Node 24.14.1): + +```text +npm run lint # eslint (js + vue) and cspell: clean +npm test # karma: TOTAL: 1061 SUCCESS (67 skipped) +``` + +Baseline on `master` (`a4aae41af`): `TOTAL: 6 FAILED, 969 SUCCESS`. The six +baseline failures (Object API search x4, Image Exporter x1, URLIndicator +default icon class x1) are environment-sensitive, are not modified by this +change set, and did not reproduce on the final branch run. The branch adds 92 +specs and no regressions; the CI `unit-test` job passes. + +### Original prompt + +```text +Do an Application Security & Development STIG and NIST SP 800-53 Rev. 5 review of Open MCT's untrusted-input boundaries (import/export JSON, notebook entries, URL plugins, CouchDB/localStorage persistence, user/role plugins, expression evaluation). Produce a findings table with file:line, CWE, ASD STIG rule ID, NIST control, severity and an explicit outcome for every boundary (satisfied / not-satisfied / needs-input / not-applicable). Then remediate the top findings in one PR with unit tests: schema validation and prototype-pollution protection on import, XSS sanitization on notebook sinks, generic error handling on persistence failures, and a structured audit-event hook for operator actions. Keep lint and the test suite green and show a baseline comparison. +``` diff --git a/src/MCT.js b/src/MCT.js index 1700eebbb2..85f74c5e61 100644 --- a/src/MCT.js +++ b/src/MCT.js @@ -24,6 +24,7 @@ import { createApp, markRaw } from 'vue'; import ActionsAPI from './api/actions/ActionsAPI.js'; import AnnotationAPI from './api/annotation/AnnotationAPI.js'; +import AuditLogger from './api/audit/AuditLogger.js'; import BrandingAPI from './api/Branding.js'; import CompositionAPI from './api/composition/CompositionAPI.js'; import EditorAPI from './api/Editor.js'; @@ -195,6 +196,14 @@ export class MCT extends EventEmitter { */ this.user = new UserAPI(this); + /** + * Structured audit records (who / what / when / outcome) for operator + * actions. Providers subscribe to receive records; nothing is persisted + * or transmitted by default. + * @type {AuditLogger} + */ + this.audit = new AuditLogger(this); + /** * An interface for managing notifications and alerts. * @type {NotificationAPI} diff --git a/src/api/audit/AuditLogger.js b/src/api/audit/AuditLogger.js new file mode 100644 index 0000000000..921546049b --- /dev/null +++ b/src/api/audit/AuditLogger.js @@ -0,0 +1,234 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { EventEmitter } from 'eventemitter3'; +import { v4 as uuid } from 'uuid'; + +/** + * @typedef {import('openmct').OpenMCT} OpenMCT + * @typedef {import('openmct').Identifier} Identifier + */ + +/** + * @typedef {'success' | 'failure'} AuditOutcome + */ + +/** + * @typedef {Object} AuditActor + * @property {string | null} id user id, or null when no user provider is configured + * @property {string | null} username user name, or null when no user provider is configured + * @property {string | null} role active role, or null when none is selected + */ + +/** + * @typedef {Object} AuditRecord + * @property {string} id unique id for this record + * @property {string} source constant component identifier ("openmct") + * @property {string} timestamp ISO 8601 UTC timestamp from the system clock + * @property {string} action the operator action, e.g. "import", "notebook.entry.create" + * @property {AuditOutcome} outcome whether the action succeeded + * @property {AuditActor} actor who performed the action + * @property {string | null} target key string of the domain object acted upon, if any + * @property {Object} details structured, action-specific context + */ + +/** + * @typedef {Object} AuditRecordInput + * @property {string} action + * @property {AuditOutcome} [outcome='success'] + * @property {Identifier | string} [target] + * @property {Object} [details] + */ + +/** + * @typedef {Object} AuditProvider + * @property {(record: AuditRecord) => void | Promise} record receives each completed audit record + */ + +const SOURCE = 'openmct'; +const OUTCOMES = new Set(['success', 'failure']); + +/** + * Emits structured audit records (who / what / when / outcome) for operator + * actions. The logger does not persist or transmit records itself; consumers + * subscribe with {@link AuditLogger#addProvider} or listen for the `record` + * event and forward records to a sink of their choosing. + * + * @extends EventEmitter + */ +export default class AuditLogger extends EventEmitter { + /** @type {OpenMCT} */ + #openmct; + /** @type {Set} */ + #providers = new Set(); + + /** + * @param {OpenMCT} openmct + */ + constructor(openmct) { + super(); + this.#openmct = openmct; + } + + /** + * Register a provider that receives every audit record. + * @param {AuditProvider} provider + * @returns {() => void} a function that removes the provider + */ + addProvider(provider) { + if (!provider || typeof provider.record !== 'function') { + throw new Error('Audit providers must implement a record(auditRecord) method'); + } + + this.#providers.add(provider); + + return () => this.removeProvider(provider); + } + + /** + * @param {AuditProvider} provider + */ + removeProvider(provider) { + this.#providers.delete(provider); + } + + /** + * @returns {boolean} true if at least one provider is registered + */ + hasProviders() { + return this.#providers.size > 0; + } + + /** + * Build and dispatch an audit record. Never throws: failures in actor + * resolution or in a provider are logged to the console and do not + * interrupt the operator action being audited. + * + * The returned promise settles once every provider has accepted or + * rejected the record, so callers that need confirmed delivery can await it. + * + * @param {AuditRecordInput} input + * @returns {Promise} the dispatched record + */ + async record(input) { + if (!input || typeof input.action !== 'string' || input.action.length === 0) { + console.error('AuditLogger.record called without an action'); + + return undefined; + } + + const outcome = OUTCOMES.has(input.outcome) ? input.outcome : 'success'; + // stamp the time of the action itself, before any asynchronous identity lookup + const timestamp = new Date().toISOString(); + const record = { + id: uuid(), + source: SOURCE, + timestamp, + action: input.action, + outcome, + actor: await this.#resolveActor(), + target: this.#normalizeTarget(input.target), + details: input.details ? { ...input.details } : {} + }; + + await this.#dispatch(record); + + return record; + } + + /** + * @returns {Promise} + */ + async #resolveActor() { + const actor = { id: null, username: null, role: null }; + const userAPI = this.#openmct.user; + + if (!userAPI?.hasProvider?.()) { + return actor; + } + + try { + // capture the role synchronously so it reflects the moment of the action + actor.role = userAPI.getActiveRole?.() ?? null; + const user = await userAPI.getCurrentUser(); + if (user) { + actor.id = user.getId?.() ?? null; + actor.username = user.getName?.() ?? null; + } + } catch (error) { + console.error('AuditLogger could not resolve the current user:', error); + } + + return actor; + } + + /** + * @param {Identifier | string | undefined} target + * @returns {string | null} + */ + #normalizeTarget(target) { + if (target === undefined || target === null) { + return null; + } + + if (typeof target === 'string') { + return target; + } + + try { + return this.#openmct.objects.makeKeyString(target); + } catch (error) { + return null; + } + } + + /** + * @param {AuditRecord} record + * @returns {Promise} settles when every provider has settled + */ + #dispatch(record) { + const deliveries = []; + for (const provider of this.#providers) { + try { + const result = provider.record(record); + if (typeof result?.then === 'function') { + deliveries.push( + result.then(undefined, (error) => { + console.error('Audit provider failed to accept record:', error); + }) + ); + } + } catch (error) { + console.error('Audit provider failed to accept record:', error); + } + } + + // emitted through the EventEmitter so on/once/off semantics are preserved + try { + this.emit('record', record); + } catch (error) { + console.error('Audit record listener failed:', error); + } + + return Promise.all(deliveries).then(() => undefined); + } +} diff --git a/src/api/audit/AuditLoggerSpec.js b/src/api/audit/AuditLoggerSpec.js new file mode 100644 index 0000000000..8a65d7c07b --- /dev/null +++ b/src/api/audit/AuditLoggerSpec.js @@ -0,0 +1,283 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { createOpenMct, resetApplicationState } from 'utils/testing'; + +import AuditLogger from './AuditLogger.js'; + +const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +describe('The AuditLogger', () => { + let openmct; + + beforeEach(() => { + openmct = createOpenMct(); + }); + + afterEach(() => { + return resetApplicationState(openmct); + }); + + it('is registered on the openmct instance', () => { + expect(openmct.audit).toBeInstanceOf(AuditLogger); + }); + + it('emits a structured record with who, what, when and outcome', async () => { + const received = []; + openmct.audit.on('record', (auditRecord) => received.push(auditRecord)); + + const auditRecord = await openmct.audit.record({ + action: 'export', + outcome: 'success', + target: { namespace: 'ns', key: 'abc' }, + details: { objectCount: 3 } + }); + + expect(received).toEqual([auditRecord]); + expect(auditRecord.id).toEqual(jasmine.any(String)); + expect(auditRecord.source).toBe('openmct'); + expect(auditRecord.timestamp).toMatch(ISO_8601); + expect(auditRecord.action).toBe('export'); + expect(auditRecord.outcome).toBe('success'); + expect(auditRecord.actor).toEqual({ id: null, username: null, role: null }); + expect(auditRecord.target).toBe('ns:abc'); + expect(auditRecord.details).toEqual({ objectCount: 3 }); + }); + + it('assigns a unique id to every record', async () => { + const [first, second] = await Promise.all([ + openmct.audit.record({ action: 'a' }), + openmct.audit.record({ action: 'b' }) + ]); + + expect(first.id).not.toEqual(second.id); + }); + + it('defaults the outcome to success and rejects unknown outcomes', async () => { + const defaulted = await openmct.audit.record({ action: 'a' }); + const unknown = await openmct.audit.record({ action: 'a', outcome: 'maybe' }); + const failure = await openmct.audit.record({ action: 'a', outcome: 'failure' }); + + expect(defaulted.outcome).toBe('success'); + expect(unknown.outcome).toBe('success'); + expect(failure.outcome).toBe('failure'); + }); + + it('accepts key-string targets and tolerates a missing target', async () => { + const keyString = await openmct.audit.record({ action: 'a', target: 'ns:abc' }); + const missing = await openmct.audit.record({ action: 'a' }); + + expect(keyString.target).toBe('ns:abc'); + expect(missing.target).toBeNull(); + }); + + it('refuses to emit a record without an action', async () => { + spyOn(console, 'error'); + const listener = jasmine.createSpy('listener'); + openmct.audit.on('record', listener); + + expect(await openmct.audit.record()).toBeUndefined(); + expect(await openmct.audit.record({})).toBeUndefined(); + expect(await openmct.audit.record({ action: '' })).toBeUndefined(); + expect(listener).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledTimes(3); + }); + + it('copies details so later mutation does not alter the record', async () => { + const details = { count: 1 }; + const auditRecord = await openmct.audit.record({ action: 'a', details }); + details.count = 2; + + expect(auditRecord.details.count).toBe(1); + }); + + describe('providers', () => { + it('delivers records to registered providers until they are removed', async () => { + const provider = { record: jasmine.createSpy('record') }; + const removeProvider = openmct.audit.addProvider(provider); + + expect(openmct.audit.hasProviders()).toBeTrue(); + + const first = await openmct.audit.record({ action: 'first' }); + expect(provider.record).toHaveBeenCalledOnceWith(first); + + removeProvider(); + await openmct.audit.record({ action: 'second' }); + + expect(provider.record).toHaveBeenCalledTimes(1); + expect(openmct.audit.hasProviders()).toBeFalse(); + }); + + it('rejects providers that do not implement record()', () => { + expect(() => openmct.audit.addProvider({})).toThrowError( + 'Audit providers must implement a record(auditRecord) method' + ); + expect(() => openmct.audit.addProvider(undefined)).toThrowError(); + }); + + it('isolates a failing provider so other providers still receive the record', async () => { + spyOn(console, 'error'); + const failing = { + record: () => { + throw new Error('sink unavailable'); + } + }; + const healthy = { record: jasmine.createSpy('record') }; + openmct.audit.addProvider(failing); + openmct.audit.addProvider(healthy); + + const auditRecord = await openmct.audit.record({ action: 'a' }); + + expect(healthy.record).toHaveBeenCalledOnceWith(auditRecord); + expect(console.error).toHaveBeenCalledWith( + 'Audit provider failed to accept record:', + jasmine.any(Error) + ); + }); + + it('logs a rejected asynchronous provider instead of surfacing an unhandled rejection', async () => { + spyOn(console, 'error'); + const rejection = new Error('remote sink rejected'); + const failing = { record: () => Promise.reject(rejection) }; + const healthy = { record: jasmine.createSpy('record') }; + openmct.audit.addProvider(failing); + openmct.audit.addProvider(healthy); + + const auditRecord = await openmct.audit.record({ action: 'a' }); + + expect(healthy.record).toHaveBeenCalledOnceWith(auditRecord); + expect(console.error).toHaveBeenCalledWith( + 'Audit provider failed to accept record:', + rejection + ); + }); + + it('resolves record() only after asynchronous providers have settled', async () => { + let finishDelivery; + let delivered = false; + openmct.audit.addProvider({ + record: () => + new Promise((resolve) => { + finishDelivery = () => { + delivered = true; + resolve(); + }; + }) + }); + + let settled = false; + const pending = openmct.audit.record({ action: 'a' }).then((auditRecord) => { + settled = true; + + return auditRecord; + }); + await new Promise((resolve) => setTimeout(resolve)); + + expect(settled).toBe(false); + finishDelivery(); + const auditRecord = await pending; + + expect(delivered).toBe(true); + expect(auditRecord.action).toBe('a'); + }); + + it('isolates a throwing record listener from providers and the caller', async () => { + spyOn(console, 'error'); + const provider = { record: jasmine.createSpy('record') }; + openmct.audit.on('record', () => { + throw new Error('listener exploded'); + }); + openmct.audit.addProvider(provider); + + const auditRecord = await openmct.audit.record({ action: 'a' }); + + expect(auditRecord.action).toBe('a'); + expect(provider.record).toHaveBeenCalledOnceWith(auditRecord); + expect(console.error).toHaveBeenCalledWith( + 'Audit record listener failed:', + jasmine.any(Error) + ); + }); + + it('honors once(), off() and listener context for record events', async () => { + const onceListener = jasmine.createSpy('once'); + const removedListener = jasmine.createSpy('removed'); + const context = { + seen: [], + contextListener(auditRecord) { + this.seen.push(auditRecord.action); + } + }; + const { contextListener } = context; + + openmct.audit.once('record', onceListener); + openmct.audit.on('record', removedListener); + openmct.audit.on('record', contextListener, context); + + await openmct.audit.record({ action: 'first' }); + openmct.audit.off('record', removedListener); + await openmct.audit.record({ action: 'second' }); + + expect(onceListener).toHaveBeenCalledTimes(1); + expect(removedListener).toHaveBeenCalledTimes(1); + expect(context.seen).toEqual(['first', 'second']); + expect(openmct.audit.listenerCount('record')).toBe(1); + + openmct.audit.off('record', contextListener, context); + }); + }); + + describe('actor resolution', () => { + it('captures the current user and active role when a user provider is set', async () => { + const user = new openmct.user.User('user-1', 'Operator One'); + openmct.user.setProvider({ + getCurrentUser: () => Promise.resolve(user), + getPossibleRoles: () => Promise.resolve(['flight']) + }); + spyOn(openmct.user, 'getActiveRole').and.returnValue('flight'); + + const auditRecord = await openmct.audit.record({ action: 'a' }); + + expect(auditRecord.actor).toEqual({ id: 'user-1', username: 'Operator One', role: 'flight' }); + }); + + it('still emits a record when user resolution fails', async () => { + spyOn(console, 'error'); + openmct.user.setProvider({ + getCurrentUser: () => Promise.reject(new Error('identity service unavailable')) + }); + + const auditRecord = await openmct.audit.record({ action: 'a' }); + + // the role is read synchronously from the user API, so it survives the lookup failure + expect(auditRecord.actor).toEqual({ + id: null, + username: null, + role: openmct.user.getActiveRole() + }); + expect(console.error).toHaveBeenCalledWith( + 'AuditLogger could not resolve the current user:', + jasmine.any(Error) + ); + }); + }); +}); diff --git a/src/api/faultmanagement/FaultManagementAPI.js b/src/api/faultmanagement/FaultManagementAPI.js index 652c158e94..09fb7d7d11 100644 --- a/src/api/faultmanagement/FaultManagementAPI.js +++ b/src/api/faultmanagement/FaultManagementAPI.js @@ -119,7 +119,9 @@ export default class FaultManagementAPI { * @returns {Promise.} - A promise that resolves when the fault is acknowledged. */ acknowledgeFault(fault, ackData) { - return this.provider.acknowledgeFault(fault, ackData); + return this.#audited('fault.acknowledge', fault, () => + this.provider.acknowledgeFault(fault, ackData) + ); } /** @@ -130,7 +132,46 @@ export default class FaultManagementAPI { * @returns {Promise.} - A promise that resolves when the fault is shelved. */ shelveFault(fault, shelveData) { - return this.provider.shelveFault(fault, shelveData); + return this.#audited( + 'fault.shelve', + fault, + () => this.provider.shelveFault(fault, shelveData), + { + shelved: shelveData?.shelved ?? true, + shelveDuration: shelveData?.shelveDuration ?? null + } + ); + } + + /** + * Runs a provider operation and emits an audit record with its outcome. + * @param {string} action + * @param {Fault} fault + * @param {() => Promise<*> | *} operation + * @param {Object} [extraDetails] + * @returns {Promise<*>} + */ + async #audited(action, fault, operation, extraDetails = {}) { + // providers may hand back either the fault itself or a { fault } wrapper + const source = fault?.fault ?? fault ?? {}; + const details = { + faultId: source.id ?? null, + faultName: source.name ?? null, + faultNamespace: source.namespace ?? null, + severity: source.severity ?? null, + ...extraDetails + }; + + try { + const result = await operation(); + this.openmct.audit?.record({ action, outcome: 'success', details }); + + return result; + } catch (error) { + this.openmct.audit?.record({ action, outcome: 'failure', details }); + + throw error; + } } /** diff --git a/src/api/faultmanagement/FaultManagementAPISpec.js b/src/api/faultmanagement/FaultManagementAPISpec.js index 363733d531..2863220c93 100644 --- a/src/api/faultmanagement/FaultManagementAPISpec.js +++ b/src/api/faultmanagement/FaultManagementAPISpec.js @@ -20,7 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -import { createOpenMct, resetApplicationState } from '../../utils/testing.js'; +import { collectAuditRecords, createOpenMct, resetApplicationState } from '../../utils/testing.js'; const faultName = 'super duper fault'; const aFault = { @@ -139,4 +139,53 @@ describe('The Fault Management API', () => { expect(faultManagementProvider.shelveFault).toHaveBeenCalledWith(aFault, aComment); expect(shelveResponse.success).toBeTrue(); }); + + describe('audit records', () => { + let audit; + + beforeEach(() => { + audit = collectAuditRecords(openmct); + }); + + afterEach(() => { + audit.stop(); + }); + + it('records a successful acknowledgement', async () => { + await openmct.faults.acknowledgeFault(aFault, { comment: aComment }); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('fault.acknowledge'); + expect(auditRecords[0].outcome).toBe('success'); + expect(auditRecords[0].details.faultName).toBe(faultName); + }); + + it('records a successful shelve with the shelving metadata', async () => { + await openmct.faults.shelveFault(aFault, { shelved: true, shelveDuration: 90000 }); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('fault.shelve'); + expect(auditRecords[0].outcome).toBe('success'); + expect(auditRecords[0].details.shelved).toBeTrue(); + expect(auditRecords[0].details.shelveDuration).toBe(90000); + }); + + it('records a failed acknowledgement and still rejects', async () => { + const providerError = new Error('provider rejected the acknowledgement'); + spyOn(faultManagementProvider, 'acknowledgeFault').and.returnValue( + Promise.reject(providerError) + ); + + await expectAsync( + openmct.faults.acknowledgeFault(aFault, { comment: aComment }) + ).toBeRejectedWith(providerError); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('fault.acknowledge'); + expect(auditRecords[0].outcome).toBe('failure'); + }); + }); }); diff --git a/src/api/objects/ObjectAPI.js b/src/api/objects/ObjectAPI.js index ec947edf2c..75133d777f 100644 --- a/src/api/objects/ObjectAPI.js +++ b/src/api/objects/ObjectAPI.js @@ -30,6 +30,7 @@ import InterceptorRegistry from './InterceptorRegistry.js'; import MutableDomainObject from './MutableDomainObject.js'; import NamespaceProvider from './NamespaceProvider.js'; import { isIdentifier, isKeyString } from './object-utils.js'; +import PersistenceError from './PersistenceError.js'; import RootObjectCompositionProvider from './RootObjectCompositionProvider.js'; import RootObjectProvider from './RootObjectProvider.js'; import RootRegistry from './RootRegistry.js'; @@ -110,7 +111,8 @@ export default class ObjectAPI { ]; this.errors = { - Conflict: ConflictError + Conflict: ConflictError, + Persistence: PersistenceError }; } diff --git a/src/api/objects/PersistenceError.js b/src/api/objects/PersistenceError.js new file mode 100644 index 0000000000..ffc02d7cf9 --- /dev/null +++ b/src/api/objects/PersistenceError.js @@ -0,0 +1,49 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +/** + * Represents a failure in a persistence provider (for example an unreachable + * database, a rejected request, or unavailable browser storage). + * + * The `message` is intentionally generic and safe to display to an operator. + * Raw provider details (status codes, server-supplied reasons, stack traces) + * are recorded by the provider via `console.error` and are not carried on the + * message so they cannot leak into notifications or dialogs. + */ +export default class PersistenceError extends Error { + static GENERIC_MESSAGE = 'The requested storage operation could not be completed.'; + + /** + * @param {string} [message] operator-safe message + * @param {{ provider?: string, operation?: string, status?: number, cause?: unknown }} [options] + */ + constructor( + message = PersistenceError.GENERIC_MESSAGE, + { provider, operation, status, cause } = {} + ) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'PersistenceError'; + this.provider = provider; + this.operation = operation; + this.status = status; + } +} diff --git a/src/api/user/ActiveRoleSynchronizer.js b/src/api/user/ActiveRoleSynchronizer.js index e390fc0db3..595383da95 100644 --- a/src/api/user/ActiveRoleSynchronizer.js +++ b/src/api/user/ActiveRoleSynchronizer.js @@ -19,7 +19,7 @@ class ActiveRoleSynchronizer { setActiveRoleFromChannelMessage(event) { const role = event.data; - this.openmct.user.setActiveRole(role); + this.openmct.user.setActiveRole(role, { synchronized: true }); } broadcastNewRole(role) { if (!this.#roleChannel.name) { diff --git a/src/api/user/UserAPI.js b/src/api/user/UserAPI.js index 1de45c1b3e..077cc07bd1 100644 --- a/src/api/user/UserAPI.js +++ b/src/api/user/UserAPI.js @@ -117,15 +117,31 @@ class UserAPI extends EventEmitter { } /** * Set the active role in session storage + * @param {string | null | undefined} role + * @param {{ synchronized?: boolean }} [options] set `synchronized` when the change + * mirrors a selection already made (and audited) in another browsing context * @returns {undefined} */ - setActiveRole(role) { + setActiveRole(role, { synchronized = false } = {}) { + const previousRole = StoragePersistence.getActiveRole() ?? null; + const newRole = role || null; + if (!role) { StoragePersistence.clearActiveRole(); } else { StoragePersistence.setActiveRole(role); } this.emit('roleChanged', role); + + // roles are only meaningful (and only reported) once a user provider exists; + // the originating context records the change, mirrors do not + if (this.hasProvider() && !synchronized && newRole !== previousRole) { + this.#openmct.audit?.record({ + action: 'user.role.change', + outcome: 'success', + details: { previousRole, newRole } + }); + } } /** diff --git a/src/api/user/UserAPISpec.js b/src/api/user/UserAPISpec.js index 5af049dd3d..da8b00419b 100644 --- a/src/api/user/UserAPISpec.js +++ b/src/api/user/UserAPISpec.js @@ -21,7 +21,7 @@ *****************************************************************************/ import ExampleUserProvider from '../../../example/exampleUser/ExampleUserProvider.js'; -import { createOpenMct, resetApplicationState } from '../../utils/testing.js'; +import { collectAuditRecords, createOpenMct, resetApplicationState } from '../../utils/testing.js'; import { MULTIPLE_PROVIDER_ERROR } from './constants.js'; describe('The User API', () => { @@ -62,4 +62,88 @@ describe('The User API', () => { expect(openmct.user.hasProvider()).toBeTrue(); }); }); + + describe('with regard to role changes', () => { + let audit; + + beforeEach(async () => { + const provider = new ExampleUserProvider(openmct); + provider.autoLogin('operator-one'); + openmct.user.setProvider(provider); + // createOpenMct() seeds an active role; clear it and drain the resulting + // record before observing so each test starts from a null role + const seededRole = openmct.user.getActiveRole(); + const setupRecords = collectAuditRecords(openmct); + openmct.user.setActiveRole(undefined); + if (seededRole !== null) { + await setupRecords.waitFor(1); + } + setupRecords.stop(); + audit = collectAuditRecords(openmct); + }); + + afterEach(() => { + audit.stop(); + openmct.user.setActiveRole(undefined); + }); + + it('emits an audit record with the previous and new role', async () => { + openmct.user.setActiveRole('flight'); + openmct.user.setActiveRole('test-conductor'); + const auditRecords = await audit.waitFor(2); + + expect(auditRecords.map((auditRecord) => auditRecord.action)).toEqual([ + 'user.role.change', + 'user.role.change' + ]); + expect(auditRecords[0].outcome).toBe('success'); + expect(auditRecords[0].actor).toEqual({ + id: jasmine.any(String), + username: 'operator-one', + role: 'flight' + }); + expect(auditRecords[0].details).toEqual({ previousRole: null, newRole: 'flight' }); + expect(auditRecords[1].details).toEqual({ + previousRole: 'flight', + newRole: 'test-conductor' + }); + }); + + it('records clearing the active role', async () => { + openmct.user.setActiveRole('flight'); + openmct.user.setActiveRole(undefined); + const auditRecords = await audit.waitFor(2); + + expect(auditRecords[1].details).toEqual({ previousRole: 'flight', newRole: null }); + expect(openmct.user.getActiveRole()).toBeNull(); + }); + + it('does not emit a record when mirroring a role change from another browsing context', async () => { + const roleChanged = jasmine.createSpy('roleChanged'); + openmct.user.on('roleChanged', roleChanged); + + openmct.user.setActiveRole('flight', { synchronized: true }); + await openmct.audit.record({ action: 'test.marker' }); + + expect(roleChanged).toHaveBeenCalledOnceWith('flight'); + expect(openmct.user.getActiveRole()).toBe('flight'); + expect(audit.records.map((auditRecord) => auditRecord.action)).toEqual(['test.marker']); + + openmct.user.off('roleChanged', roleChanged); + }); + + it('does not emit a record when the role is unchanged', async () => { + openmct.user.setActiveRole(undefined); + openmct.user.setActiveRole('flight'); + openmct.user.setActiveRole('flight'); + // a marker record is dispatched after anything already in flight + await openmct.audit.record({ action: 'test.marker' }); + + expect(audit.records.map((auditRecord) => auditRecord.action)).toEqual([ + 'user.role.change', + 'test.marker' + ]); + expect(audit.records[0].details).toEqual({ previousRole: null, newRole: 'flight' }); + }); + }); }); diff --git a/src/plugins/exportAsJSONAction/ExportAsJSONAction.js b/src/plugins/exportAsJSONAction/ExportAsJSONAction.js index 0e283dba68..78bad2f1a5 100644 --- a/src/plugins/exportAsJSONAction/ExportAsJSONAction.js +++ b/src/plugins/exportAsJSONAction/ExportAsJSONAction.js @@ -99,9 +99,16 @@ class ExportAsJSONAction { this.dialog.dismiss(); this.dialog = null; this.#resetCounts(); + console.error('Export as JSON failed:', error); this.#openmct.notifications.error({ title: 'Export as JSON failed', - message: error.message + message: 'The selected object could not be exported.' + }); + this.#openmct.audit?.record({ + action: 'export', + outcome: 'failure', + target: root.identifier, + details: { rootType: root.type } }); }); } @@ -183,11 +190,13 @@ class ExportAsJSONAction { this.tree[originalKeyString] = child; } - this.#write(child).then(() => { + return this.#write(child).then(() => { this.exportedCount++; this.#updateProgress(); }); } + + return Promise.resolve(); } /** @@ -381,6 +390,15 @@ class ExportAsJSONAction { */ saveAs(completedTree) { this.JSONExportService.export(completedTree, { filename: this.root.name + '.json' }); + this.#openmct.audit?.record({ + action: 'export', + outcome: 'success', + target: this.root.identifier, + details: { + rootType: this.root.type, + objectCount: Object.keys(completedTree.openmct).length + } + }); } /** * @private diff --git a/src/plugins/exportAsJSONAction/ExportAsJSONActionSpec.js b/src/plugins/exportAsJSONAction/ExportAsJSONActionSpec.js index 6b12619fce..100b6accbb 100644 --- a/src/plugins/exportAsJSONAction/ExportAsJSONActionSpec.js +++ b/src/plugins/exportAsJSONAction/ExportAsJSONActionSpec.js @@ -386,4 +386,103 @@ describe('Export as JSON plugin', () => { exportAsJSONAction.invoke([parent]); }); + + describe('audit and error handling', () => { + let leaf; + + function waitForAuditRecord() { + return new Promise((resolve) => { + const unsubscribe = openmct.audit.addProvider({ + record: (auditRecord) => { + unsubscribe(); + resolve(auditRecord); + } + }); + }); + } + + beforeEach(() => { + leaf = { + composition: [], + identifier: { key: 'leaf', namespace: '' }, + name: 'Leaf', + type: 'folder', + modified: 1503598132428, + location: 'mine', + persisted: 1503598132428 + }; + spyOn(openmct.composition, 'get').and.returnValue({ load: () => Promise.resolve([]) }); + spyOn(exportAsJSONAction.JSONExportService, 'export'); + }); + + it('emits a success audit record when an export completes', async () => { + const pendingRecord = waitForAuditRecord(); + + exportAsJSONAction.invoke([leaf]); + const auditRecord = await pendingRecord; + + expect(exportAsJSONAction.JSONExportService.export).toHaveBeenCalled(); + expect(auditRecord.action).toBe('export'); + expect(auditRecord.outcome).toBe('success'); + expect(auditRecord.target).toBe('leaf'); + expect(auditRecord.details).toEqual({ rootType: 'folder', objectCount: 1 }); + }); + + it('shows a generic message, logs the raw error and emits a failure record when export fails', async () => { + const rawError = new Error('CouchDB at 10.0.0.5:5984 returned 500'); + openmct.composition.get.and.returnValue({ load: () => Promise.reject(rawError) }); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + const pendingRecord = waitForAuditRecord(); + + exportAsJSONAction.invoke([leaf]); + const auditRecord = await pendingRecord; + + expect(exportAsJSONAction.JSONExportService.export).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith('Export as JSON failed:', rawError); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith({ + title: 'Export as JSON failed', + message: 'The selected object could not be exported.' + }); + expect(JSON.stringify(openmct.notifications.error.calls.allArgs())).not.toContain('10.0.0.5'); + expect(auditRecord.action).toBe('export'); + expect(auditRecord.outcome).toBe('failure'); + expect(auditRecord.target).toBe('leaf'); + }); + + it('propagates a descendant load failure to the generic handler and closes the dialog', async () => { + const child = { + composition: [], + identifier: { key: 'child', namespace: '' }, + name: 'Child', + type: 'folder', + location: 'leaf', + persisted: 1503598132428 + }; + const rawError = new Error('CouchDB at 10.0.0.5:5984 returned 500'); + openmct.composition.get.and.callFake((parent) => ({ + load: () => + parent.identifier.key === 'child' ? Promise.reject(rawError) : Promise.resolve([child]) + })); + const dismiss = jasmine.createSpy('dismiss'); + spyOn(openmct.overlays, 'progressDialog').and.returnValue({ + show: () => {}, + updateProgress: () => {}, + dismiss + }); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + const pendingRecord = waitForAuditRecord(); + + exportAsJSONAction.invoke([leaf]); + const auditRecord = await pendingRecord; + + expect(exportAsJSONAction.JSONExportService.export).not.toHaveBeenCalled(); + expect(dismiss).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith('Export as JSON failed:', rawError); + expect(openmct.notifications.error).toHaveBeenCalledTimes(1); + expect(auditRecord.outcome).toBe('failure'); + expect(auditRecord.target).toBe('leaf'); + }); + }); }); diff --git a/src/plugins/formActions/CreateAction.js b/src/plugins/formActions/CreateAction.js index cc4f00766e..9c765c4f2a 100644 --- a/src/plugins/formActions/CreateAction.js +++ b/src/plugins/formActions/CreateAction.js @@ -91,7 +91,7 @@ class CreateAction extends PropertiesAction { this.openmct.notifications.info('Save successful'); } catch (err) { console.error(err); - this.openmct.notifications.error(`Error saving objects: ${err}`); + this.openmct.notifications.error('Error saving objects'); } finally { this.openmct.objects.destroyMutable(parentDomainObject); dialog.dismiss(); diff --git a/src/plugins/formActions/CreateActionSpec.js b/src/plugins/formActions/CreateActionSpec.js index ebfbe4a4d8..e3fe15dac8 100644 --- a/src/plugins/formActions/CreateActionSpec.js +++ b/src/plugins/formActions/CreateActionSpec.js @@ -120,4 +120,27 @@ describe('The create action plugin', () => { }); }); }); + + describe('when saving fails', () => { + it('shows a generic message and keeps the raw error in the console', async () => { + const rawError = new Error('ECONNREFUSED 10.0.0.5:5984 /internal/path'); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + spyOn(openmct.objects, 'save').and.returnValue(Promise.reject(rawError)); + + const createAction = openmct.actions.getAction(CREATE_ACTION_KEY); + createAction.domainObject = openmct.objects.toMutable({ + name: 'Unnamed Folder', + type: 'folder', + identifier: { key: 'new-folder', namespace: '' } + }); + await createAction._onSave({ + name: 'test', + location: [{ identifier: { key: 'mock-folder', namespace: '' }, type: 'folder' }] + }); + + expect(openmct.notifications.error).toHaveBeenCalledOnceWith('Error saving objects'); + expect(console.error).toHaveBeenCalledWith(rawError); + }); + }); }); diff --git a/src/plugins/importFromJSONAction/ImportFromJSONAction.js b/src/plugins/importFromJSONAction/ImportFromJSONAction.js index 77fa1dfda9..a3d201fa07 100644 --- a/src/plugins/importFromJSONAction/ImportFromJSONAction.js +++ b/src/plugins/importFromJSONAction/ImportFromJSONAction.js @@ -24,7 +24,10 @@ import { parseKeyString } from 'objectUtils'; import { filter__proto__ } from 'utils/sanitization'; import { v4 as uuid } from 'uuid'; +import { IMPORT_REJECTED_MESSAGE, validateImportTree } from './importValidation.js'; + const IMPORT_FROM_JSON_ACTION_KEY = 'import.JSON'; +const SAVE_FAILED_MESSAGE = 'Import failed: one or more objects could not be saved.'; class ImportFromJSONAction { constructor(openmct) { @@ -74,9 +77,35 @@ class ImportFromJSONAction { onSave(object, changes) { const selectFile = changes.selectFile; const jsonTree = selectFile.body; - const objectTree = JSON.parse(jsonTree, filter__proto__); + let objectTree; + + try { + objectTree = validateImportTree(JSON.parse(jsonTree, filter__proto__)); + } catch (error) { + this._rejectImport(object, error); + + return Promise.resolve(); + } - this._importObjectTree(object, objectTree); + return this._importObjectTree(object, objectTree); + } + + /** + * Logs the detailed reason an import was refused and shows a generic + * message to the operator. + * @private + * @param {Object} target the object the import was attempted into + * @param {Error} error + */ + _rejectImport(target, error) { + console.error('Import from JSON rejected:', error.message, error.errors ?? ''); + this.openmct.notifications.error(IMPORT_REJECTED_MESSAGE); + this.openmct.audit?.record({ + action: 'import', + outcome: 'failure', + target: target?.identifier, + details: { reason: error.name } + }); } /** @@ -300,8 +329,12 @@ class ImportFromJSONAction { try { let persistedObjects = 0; - // make saving objects objects 20% of the progress bar - await Promise.all( + // make saving objects objects 20% of the progress bar. + // Saves are not transactional across providers; wait for every save to settle + // so no write is still in flight when failure is reported. The imported root + // is only linked into the target composition below, after all saves succeed, + // so partially saved objects remain unreachable from the object tree. + const results = await Promise.allSettled( objectsToCreate.map(async (objectToCreate) => { persistedObjects++; const percentPersisted = @@ -311,10 +344,34 @@ class ImportFromJSONAction { await this._instantiate(objectToCreate); }) ); - } catch (error) { - this.openmct.notifications.error('Error saving objects'); + const failures = results.filter((result) => result.status === 'rejected'); + if (failures.length > 0) { + // there is no delete in the object API, so objects whose save succeeded + // stay in storage; they are never linked, and their keys are recorded so + // an administrator can locate them + const persistedKeys = objectsToCreate + .filter((objectToCreate, index) => results[index].status === 'fulfilled') + .map((objectToCreate) => this.openmct.objects.makeKeyString(objectToCreate.identifier)); + console.error( + `Import from JSON failed while saving ${failures.length} of ${objectsToCreate.length} objects:`, + failures.map((failure) => failure.reason), + 'Unlinked objects left in storage:', + persistedKeys + ); + this.openmct.notifications.error(SAVE_FAILED_MESSAGE); + this.openmct.audit?.record({ + action: 'import', + outcome: 'failure', + target: domainObject.identifier, + details: { + objectCount: objectsToCreate.length, + failedCount: failures.length, + unlinkedKeys: persistedKeys + } + }); - throw error; + return; + } } finally { importDialog.dismiss(); } @@ -323,8 +380,20 @@ class ImportFromJSONAction { let domainObjectKeyString = this.openmct.objects.makeKeyString(domainObject.identifier); this.openmct.objects.mutate(rootObj, 'location', domainObjectKeyString); compositionCollection.add(rootObj); + this.openmct.audit?.record({ + action: 'import', + outcome: 'success', + target: domainObject.identifier, + details: { objectCount: objectsToCreate.length, rootType: rootObj.type } + }); } else { importDialog.dismiss(); + this.openmct.audit?.record({ + action: 'import', + outcome: 'failure', + target: domainObject.identifier, + details: { reason: 'CompositionPolicy', rootType: rootObj.type } + }); const cannotImportDialog = this.openmct.overlays.dialog({ iconClass: 'alert', message: "We're sorry, but you cannot import that object type into this object.", @@ -365,7 +434,7 @@ class ImportFromJSONAction { control: 'file-input', required: true, text: 'Select File...', - validate: this._validateJSON, + validate: (data) => this._validateJSON(data, domainObject), type: 'application/json' } ] @@ -379,32 +448,26 @@ class ImportFromJSONAction { }); } /** + * Form-level validation of the selected file. Rejections are reported the + * same way as rejections at save time so every refused import is audited. * @private * @param {Object} data + * @param {Object} [target] the object the import is being attempted into * @returns {boolean} */ - _validateJSON(data) { + _validateJSON(data, target) { const value = data.value; const objectTree = value && value.body; - let json; - let success = true; - try { - json = JSON.parse(objectTree); - } catch (e) { - success = false; - } - if (success && (!json.openmct || !json.rootId)) { - success = false; - } + try { + validateImportTree(JSON.parse(objectTree, filter__proto__)); + } catch (error) { + this._rejectImport(target, error); - if (!success) { - this.openmct.notifications.error( - 'Invalid File: The selected file was either invalid JSON or was not formatted properly for import into Open MCT.' - ); + return false; } - return success; + return true; } } diff --git a/src/plugins/importFromJSONAction/ImportFromJSONActionSpec.js b/src/plugins/importFromJSONAction/ImportFromJSONActionSpec.js index de9615d64b..d9271244bb 100644 --- a/src/plugins/importFromJSONAction/ImportFromJSONActionSpec.js +++ b/src/plugins/importFromJSONAction/ImportFromJSONActionSpec.js @@ -20,7 +20,7 @@ * at runtime from the About dialog for additional information. *****************************************************************************/ -import { createOpenMct, resetApplicationState } from 'utils/testing'; +import { collectAuditRecords, createOpenMct, resetApplicationState } from 'utils/testing'; let openmct; let importFromJSONAction; @@ -209,4 +209,292 @@ describe('The import JSON action', function () { fail(error); } }); + + describe('input validation before persistence', () => { + let audit; + + beforeEach(() => { + audit = collectAuditRecords(openmct); + spyOn(console, 'error'); + spyOn(openmct.objects, 'save').and.callFake((model) => Promise.resolve(model)); + spyOn(openmct.notifications, 'error'); + spyOn(openmct.overlays, 'progressDialog').and.returnValue({ + updateProgress: () => {}, + dismiss: () => {} + }); + }); + + afterEach(() => { + audit.stop(); + }); + + function invalidTrees() { + const key = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + function base() { + return { + openmct: { + [key]: { + identifier: { key, namespace: '' }, + name: 'Unnamed Folder', + type: 'folder', + composition: [], + location: 'mine' + } + }, + rootId: key + }; + } + + const reservedKey = base(); + reservedKey.openmct[key].configuration = { constructor: { prototype: {} } }; + + const badIdentifier = base(); + badIdentifier.openmct[key].identifier = { key: 'someone-else', namespace: '' }; + + const badType = base(); + badType.openmct[key].type = ''; + + const badComposition = base(); + badComposition.openmct[key].composition = [{ nope: true }]; + + const badRoot = base(); + badRoot.rootId = 'missing'; + + return { reservedKey, badIdentifier, badType, badComposition, badRoot }; + } + + Object.entries({ + 'a reserved key': 'reservedKey', + 'a mismatched identifier': 'badIdentifier', + 'a malformed type': 'badType', + 'a malformed composition reference': 'badComposition', + 'an unknown rootId': 'badRoot' + }).forEach(([label, treeName]) => { + it(`rejects a payload with ${label} without persisting anything`, async () => { + const body = JSON.stringify(invalidTrees()[treeName]); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + + expect(openmct.objects.save).not.toHaveBeenCalled(); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith( + 'Import failed: the selected file is not a valid Open MCT export or contains unsupported content.' + ); + expect(console.error).toHaveBeenCalled(); + }); + }); + + it('emits a failure audit record when a payload is rejected', async () => { + const body = JSON.stringify(invalidTrees().badType); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('import'); + expect(auditRecords[0].outcome).toBe('failure'); + expect(auditRecords[0].target).toBe(folderObject.identifier.key); + expect(auditRecords[0].details.reason).toBe('ImportValidationError'); + }); + + it('emits a success audit record when a payload is imported', async () => { + const key = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + const body = JSON.stringify({ + openmct: { + [key]: { + identifier: { key, namespace: '' }, + name: 'Unnamed Folder', + type: 'folder', + composition: [], + location: 'mine' + } + }, + rootId: key + }); + spyOn(openmct.composition, 'get').and.returnValue({ add: () => {} }); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + const auditRecords = await audit.waitFor(1); + + expect(openmct.objects.save).toHaveBeenCalled(); + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('import'); + expect(auditRecords[0].outcome).toBe('success'); + expect(auditRecords[0].details.objectCount).toBe(1); + expect(auditRecords[0].details.rootType).toBe('folder'); + }); + + it('emits a failure audit record and persists nothing when the composition policy rejects the root', async () => { + const key = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + const body = JSON.stringify({ + openmct: { + [key]: { + identifier: { key, namespace: '' }, + name: 'Unnamed Folder', + type: 'folder', + composition: [], + location: 'mine' + } + }, + rootId: key + }); + spyOn(openmct.composition, 'checkPolicy').and.returnValue(false); + const dismiss = jasmine.createSpy('dismiss'); + spyOn(openmct.overlays, 'dialog').and.returnValue({ dismiss }); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + const auditRecords = await audit.waitFor(1); + + expect(openmct.objects.save).not.toHaveBeenCalled(); + expect(openmct.overlays.dialog).toHaveBeenCalledTimes(1); + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('import'); + expect(auditRecords[0].outcome).toBe('failure'); + expect(auditRecords[0].target).toBe(folderObject.identifier.key); + expect(auditRecords[0].details).toEqual({ reason: 'CompositionPolicy', rootType: 'folder' }); + }); + + it('shows a generic message and logs the raw error when saving fails', async () => { + const key = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + const body = JSON.stringify({ + openmct: { + [key]: { + identifier: { key, namespace: '' }, + name: 'Unnamed Folder', + type: 'folder', + composition: [], + location: 'mine' + } + }, + rootId: key + }); + const rawError = new Error('ECONNREFUSED 10.0.0.5:5984 /internal/path'); + openmct.objects.save.and.returnValue(Promise.reject(rawError)); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + const auditRecords = await audit.waitFor(1); + + expect(openmct.notifications.error).toHaveBeenCalledOnceWith( + 'Import failed: one or more objects could not be saved.' + ); + const shownMessages = openmct.notifications.error.calls.allArgs().flat().join(' '); + expect(shownMessages).not.toContain('ECONNREFUSED'); + expect(console.error).toHaveBeenCalledWith( + 'Import from JSON failed while saving 1 of 1 objects:', + [rawError], + 'Unlinked objects left in storage:', + [] + ); + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].outcome).toBe('failure'); + expect(auditRecords[0].details).toEqual({ + objectCount: 1, + failedCount: 1, + unlinkedKeys: [] + }); + }); + + it('waits for every save to settle and does not link the root when one save fails', async () => { + const rootKey = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + const childKey = '0a2b9ef1-2f4d-4a3c-9b1e-2f6a2f5c1d11'; + const body = JSON.stringify({ + openmct: { + [rootKey]: { + identifier: { key: rootKey, namespace: '' }, + name: 'Root', + type: 'folder', + composition: [{ key: childKey, namespace: '' }], + location: 'mine' + }, + [childKey]: { + identifier: { key: childKey, namespace: '' }, + name: 'Child', + type: 'folder', + composition: [], + location: rootKey + } + }, + rootId: rootKey + }); + let rootSaveSettled = false; + openmct.objects.save.and.callFake((model) => { + if (model.name === 'Child') { + return Promise.reject(new Error('quota exceeded')); + } + + return new Promise((resolve) => + setTimeout(() => { + rootSaveSettled = true; + resolve(true); + }, 20) + ); + }); + const compositionCollection = jasmine.createSpyObj('composition', ['add']); + spyOn(openmct.composition, 'get').and.returnValue(compositionCollection); + + await importFromJSONAction.onSave(folderObject, { selectFile: { body } }); + const auditRecords = await audit.waitFor(1); + + expect(rootSaveSettled).toBe(true); + expect(compositionCollection.add).not.toHaveBeenCalled(); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith( + 'Import failed: one or more objects could not be saved.' + ); + expect(auditRecords[0].outcome).toBe('failure'); + expect(auditRecords[0].details.objectCount).toBe(2); + expect(auditRecords[0].details.failedCount).toBe(1); + expect(auditRecords[0].details.unlinkedKeys.length).toBe(1); + expect(auditRecords[0].details.unlinkedKeys[0]).toBe( + openmct.objects.makeKeyString(openmct.objects.save.calls.argsFor(0)[0].identifier) + ); + }); + + it('rejects invalid files in the form validator with a generic message and a failure audit record', async () => { + function validator(body) { + return importFromJSONAction._validateJSON({ value: { body } }, folderObject); + } + + expect(validator('not json')).toBeFalse(); + expect(validator('{"openmct":{},"rootId":"x"}')).toBeFalse(); + expect(validator('{"__proto__":{"polluted":true},"openmct":{},"rootId":"x"}')).toBeFalse(); + expect(openmct.notifications.error).toHaveBeenCalledTimes(3); + openmct.notifications.error.calls.allArgs().forEach(([message]) => { + expect(message).toBe( + 'Import failed: the selected file is not a valid Open MCT export or contains unsupported content.' + ); + }); + + const auditRecords = await audit.waitFor(3); + expect(auditRecords.length).toBe(3); + auditRecords.forEach((auditRecord) => { + expect(auditRecord.action).toBe('import'); + expect(auditRecord.outcome).toBe('failure'); + expect(auditRecord.target).toBe(openmct.objects.makeKeyString(folderObject.identifier)); + }); + expect(auditRecords.map((auditRecord) => auditRecord.details.reason)).toEqual([ + 'SyntaxError', + 'ImportValidationError', + 'ImportValidationError' + ]); + }); + + it('accepts a well-formed export in the form validator without notifying or auditing', () => { + const key = 'c28d230d-e909-4a3e-9840-d9ef469dda70'; + const body = JSON.stringify({ + openmct: { + [key]: { + identifier: { key, namespace: '' }, + name: 'Unnamed Folder', + type: 'folder', + composition: [], + location: 'mine' + } + }, + rootId: key + }); + + expect(importFromJSONAction._validateJSON({ value: { body } }, folderObject)).toBeTrue(); + expect(openmct.notifications.error).not.toHaveBeenCalled(); + expect(audit.records.length).toBe(0); + }); + }); }); diff --git a/src/plugins/importFromJSONAction/importValidation.js b/src/plugins/importFromJSONAction/importValidation.js new file mode 100644 index 0000000000..2824d12f9e --- /dev/null +++ b/src/plugins/importFromJSONAction/importValidation.js @@ -0,0 +1,282 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { makeKeyString } from 'objectUtils'; + +/** + * Property names that can alter the prototype chain of an object when they are + * assigned to, and are never legitimate keys in an exported object tree. + */ +const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Type keys are interpolated into CSS class names (`is-object-type-`) and + * used as registry lookup keys, so they are restricted to the characters every + * type registered in this repository already uses. Identifier and depth bounds + * exist to cap the work a hostile file can demand; they are an order of + * magnitude above what exported object trees produce. + */ +const TYPE_KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/; +const MAX_IDENTIFIER_LENGTH = 512; +const MAX_ERRORS = 20; +const MAX_DEPTH = 64; + +/** + * Operator-facing message shown whenever an import file is rejected. It is + * intentionally generic; the specific reasons are logged for diagnostics only. + */ +const IMPORT_REJECTED_MESSAGE = + 'Import failed: the selected file is not a valid Open MCT export or contains unsupported content.'; + +class ImportValidationError extends Error { + /** + * @param {string[]} errors the individual validation failures + */ + constructor(errors) { + super(`Import validation failed with ${errors.length} error(s)`); + this.name = 'ImportValidationError'; + this.errors = errors; + } +} + +function isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const proto = Object.getPrototypeOf(value); + + return proto === Object.prototype || proto === null; +} + +function isNonEmptyString(value, maxLength = MAX_IDENTIFIER_LENGTH) { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; +} + +function describe(path) { + return path.length ? path.join('.') : ''; +} + +/** + * Walks every own property of a JSON value and reports reserved keys anywhere + * in the graph (including nested configuration blobs). Nesting deeper than + * MAX_DEPTH is rejected outright so a hostile file cannot exhaust the stack. + */ +function collectReservedKeys(value, path, errors) { + if (errors.length >= MAX_ERRORS) { + return; + } + + if (value === null || typeof value !== 'object') { + return; + } + + if (path.length >= MAX_DEPTH) { + errors.push(`Nesting deeper than ${MAX_DEPTH} levels at ${describe(path)}`); + + return; + } + + if (Array.isArray(value)) { + for (let index = 0; index < value.length && errors.length < MAX_ERRORS; index++) { + collectReservedKeys(value[index], [...path, `[${index}]`], errors); + } + + return; + } + + for (const key of Object.getOwnPropertyNames(value)) { + if (errors.length >= MAX_ERRORS) { + return; + } + + if (RESERVED_KEYS.has(key)) { + errors.push(`Reserved property "${key}" is not allowed at ${describe(path)}`); + continue; + } + + collectReservedKeys(value[key], [...path, key], errors); + } +} + +/** + * A reference may be a key string or an identifier object. + * @returns {string|undefined} a normalized key string, or undefined if invalid + */ +function normalizeReference(reference) { + if (isNonEmptyString(reference)) { + return reference; + } + + if ( + isPlainObject(reference) && + isNonEmptyString(reference.key) && + typeof reference.namespace === 'string' && + reference.namespace.length <= MAX_IDENTIFIER_LENGTH + ) { + return makeKeyString(reference); + } + + return undefined; +} + +function validateDomainObjectModel(keyString, model, errors) { + const where = `object "${keyString}"`; + + if (!isPlainObject(model)) { + errors.push(`${where} is not an object`); + + return; + } + + const identifierKeyString = normalizeReference(model.identifier); + if (identifierKeyString === undefined || !isPlainObject(model.identifier)) { + errors.push(`${where} has a missing or malformed identifier`); + } else if (identifierKeyString !== keyString) { + errors.push(`${where} has an identifier that does not match its key`); + } + + if (!isNonEmptyString(model.type, 128) || !TYPE_KEY_PATTERN.test(model.type)) { + errors.push(`${where} has a missing or malformed type`); + } + + if (model.name !== undefined && typeof model.name !== 'string') { + errors.push(`${where} has a non-string name`); + } + + if ( + model.location !== undefined && + model.location !== null && + typeof model.location !== 'string' + ) { + errors.push(`${where} has a malformed location`); + } + + if (model.composition !== undefined) { + if (!Array.isArray(model.composition)) { + errors.push(`${where} has a composition that is not an array`); + } else { + model.composition.forEach((reference, index) => { + if (normalizeReference(reference) === undefined) { + errors.push(`${where} has a malformed composition reference at index ${index}`); + } + }); + } + } + + const objectStyles = model.configuration?.objectStyles; + if (objectStyles !== undefined) { + if (!isPlainObject(objectStyles)) { + errors.push(`${where} has malformed object styles`); + } else { + const styleEntries = [objectStyles, ...Object.values(objectStyles)]; + styleEntries.forEach((entry) => { + const conditionSetIdentifier = entry?.conditionSetIdentifier; + if ( + conditionSetIdentifier !== undefined && + normalizeReference(conditionSetIdentifier) === undefined + ) { + errors.push(`${where} has a malformed condition set reference`); + } + }); + } + } +} + +/** + * Validates a parsed Import-from-JSON payload before any of it is persisted. + * + * Structural rules enforced: + * - top level is a plain object with an `openmct` object map and a `rootId` string + * - `rootId` refers to an entry of `openmct` + * - no `__proto__`, `constructor` or `prototype` keys anywhere in the graph + * - each entry has an identifier matching its key, a well-formed type key, + * and well-formed composition / condition set references + * + * @param {unknown} tree the parsed JSON payload + * @returns {string[]} an empty array when valid, otherwise the list of failures + */ +function getImportTreeErrors(tree) { + const errors = []; + + if (!isPlainObject(tree)) { + return ['Import payload is not an object']; + } + + collectReservedKeys(tree, [], errors); + if (errors.length) { + return errors; + } + + if (!isPlainObject(tree.openmct)) { + errors.push('Import payload is missing the "openmct" object map'); + } + + if (!isNonEmptyString(tree.rootId)) { + errors.push('Import payload is missing a "rootId"'); + } + + if (errors.length) { + return errors; + } + + const entries = Object.entries(tree.openmct); + if (entries.length === 0) { + errors.push('Import payload does not contain any objects'); + } + + if (!Object.hasOwn(tree.openmct, tree.rootId)) { + errors.push('Import payload "rootId" does not refer to an object in the payload'); + } + + for (const [keyString, model] of entries) { + if (errors.length >= MAX_ERRORS) { + break; + } + + validateDomainObjectModel(keyString, model, errors); + } + + return errors; +} + +/** + * @param {unknown} tree the parsed JSON payload + * @throws {ImportValidationError} when the payload is not a well-formed export + */ +function validateImportTree(tree) { + const errors = getImportTreeErrors(tree); + + if (errors.length) { + throw new ImportValidationError(errors); + } + + return tree; +} + +export { + getImportTreeErrors, + IMPORT_REJECTED_MESSAGE, + ImportValidationError, + RESERVED_KEYS, + validateImportTree +}; diff --git a/src/plugins/importFromJSONAction/importValidationSpec.js b/src/plugins/importFromJSONAction/importValidationSpec.js new file mode 100644 index 0000000000..99ee1ccaae --- /dev/null +++ b/src/plugins/importFromJSONAction/importValidationSpec.js @@ -0,0 +1,326 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { + getImportTreeErrors, + ImportValidationError, + RESERVED_KEYS, + validateImportTree +} from './importValidation.js'; + +const ROOT_KEY = '7323f02a-06ac-438d-bd58-6d6e33b8741e'; +const CHILD_KEY = '9f6c2d21-5ec8-434c-9fe8-31614ae6d7e6'; + +function validTree() { + return { + openmct: { + [ROOT_KEY]: { + identifier: { namespace: '', key: ROOT_KEY }, + name: 'Some Folder', + type: 'folder', + composition: [{ namespace: '', key: CHILD_KEY }], + location: 'mine', + configuration: { + objectStyles: { + conditionSetIdentifier: { namespace: '', key: CHILD_KEY }, + staticStyle: { style: { color: 'red' } } + } + } + }, + [CHILD_KEY]: { + identifier: { namespace: '', key: CHILD_KEY }, + name: 'Some Clock', + type: 'clock', + location: ROOT_KEY, + configuration: { timezone: 'UTC' } + } + }, + rootId: ROOT_KEY + }; +} + +describe('Import-from-JSON validation', () => { + it('accepts a well-formed export tree', () => { + const tree = validTree(); + + expect(getImportTreeErrors(tree)).toEqual([]); + expect(validateImportTree(tree)).toBe(tree); + }); + + it('accepts key-string composition references', () => { + const tree = validTree(); + tree.openmct[ROOT_KEY].composition = [CHILD_KEY, `other:${CHILD_KEY}`]; + + expect(getImportTreeErrors(tree)).toEqual([]); + }); + + it('accepts plugin-style type keys, namespaced identifiers and deep plugin configuration', () => { + const tree = validTree(); + const pluginKey = 'plugin:9f6c2d21-5ec8-434c-9fe8-31614ae6d7e6'; + let deepConfiguration = { leaf: true }; + for (let level = 0; level < 40; level++) { + deepConfiguration = { nested: deepConfiguration }; + } + + tree.openmct[CHILD_KEY].type = 'telemetry.plot.overlay'; + tree.openmct[ROOT_KEY].composition.push({ namespace: 'plugin', key: CHILD_KEY }); + tree.openmct[pluginKey] = { + identifier: { namespace: 'plugin', key: CHILD_KEY }, + name: 'Plugin Object', + type: 'example.state-generator_v2-Custom', + location: ROOT_KEY, + configuration: deepConfiguration + }; + + expect(getImportTreeErrors(tree)).toEqual([]); + }); + + it('rejects payloads that are not objects', () => { + [null, undefined, 'string', 42, [], true].forEach((payload) => { + expect(getImportTreeErrors(payload).length).toBeGreaterThan(0); + expect(() => validateImportTree(payload)).toThrowError(ImportValidationError); + }); + }); + + it('rejects payloads without an "openmct" map or "rootId"', () => { + expect(getImportTreeErrors({ rootId: ROOT_KEY })).toContain( + 'Import payload is missing the "openmct" object map' + ); + expect(getImportTreeErrors({ openmct: {} })).toContain('Import payload is missing a "rootId"'); + expect(getImportTreeErrors({ openmct: [], rootId: ROOT_KEY }).length).toBeGreaterThan(0); + expect(getImportTreeErrors({ openmct: {}, rootId: 7 }).length).toBeGreaterThan(0); + }); + + it('rejects an empty object map and a rootId that is not in the map', () => { + expect(getImportTreeErrors({ openmct: {}, rootId: ROOT_KEY })).toEqual( + jasmine.arrayContaining([ + 'Import payload does not contain any objects', + 'Import payload "rootId" does not refer to an object in the payload' + ]) + ); + + const tree = validTree(); + tree.rootId = 'not-present'; + expect(getImportTreeErrors(tree)).toContain( + 'Import payload "rootId" does not refer to an object in the payload' + ); + }); + + RESERVED_KEYS.forEach((reservedKey) => { + it(`rejects a "${reservedKey}" key at the top level`, () => { + const tree = validTree(); + // JSON.parse (without a reviver) produces own properties for these names + const polluted = JSON.parse( + JSON.stringify(tree).replace('"rootId"', `"${reservedKey}":{"polluted":true},"rootId"`) + ); + + const errors = getImportTreeErrors(polluted); + expect(errors.length).toBe(1); + expect(errors[0]).toContain(`Reserved property "${reservedKey}"`); + }); + + it(`rejects a "${reservedKey}" key nested inside object configuration`, () => { + const tree = validTree(); + const polluted = JSON.parse( + JSON.stringify(tree).replace( + '"timezone":"UTC"', + `"timezone":"UTC","${reservedKey}":{"toString":"x"}` + ) + ); + + const errors = getImportTreeErrors(polluted); + expect(errors.length).toBe(1); + expect(errors[0]).toContain(`Reserved property "${reservedKey}"`); + expect(errors[0]).toContain('configuration'); + }); + }); + + it('rejects excessively deep nesting without exhausting the stack', () => { + const tree = validTree(); + let deep = {}; + const configuration = { deep }; + for (let level = 0; level < 5000; level++) { + deep.next = {}; + deep = deep.next; + } + tree.openmct[CHILD_KEY].configuration = configuration; + + const errors = getImportTreeErrors(tree); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Nesting deeper than 64 levels'); + }); + + it('accepts nesting up to the depth limit', () => { + const tree = validTree(); + let deep = {}; + const configuration = { deep }; + // tree -> openmct -> child -> configuration -> deep is already 4 levels + for (let level = 0; level < 55; level++) { + deep.next = {}; + deep = deep.next; + } + tree.openmct[CHILD_KEY].configuration = configuration; + + expect(getImportTreeErrors(tree)).toEqual([]); + }); + + it('stops collecting after the error limit is reached', () => { + const tree = validTree(); + const flooded = JSON.parse( + JSON.stringify(tree).replace( + '"timezone":"UTC"', + '"timezone":"UTC",' + + Array.from({ length: 50 }, (_, i) => `"k${i}":{"__proto__":{"x":1}}`).join(',') + ) + ); + + expect(getImportTreeErrors(flooded).length).toBe(20); + }); + + it('does not treat inherited prototype properties as reserved keys', () => { + // a plain object only has inherited "constructor"; that must not be reported + expect(getImportTreeErrors(validTree())).toEqual([]); + }); + + it('rejects objects with a missing or malformed identifier', () => { + const missing = validTree(); + delete missing.openmct[CHILD_KEY].identifier; + expect(getImportTreeErrors(missing)).toContain( + `object "${CHILD_KEY}" has a missing or malformed identifier` + ); + + const malformed = validTree(); + malformed.openmct[CHILD_KEY].identifier = { namespace: 5, key: {} }; + expect(getImportTreeErrors(malformed)).toContain( + `object "${CHILD_KEY}" has a missing or malformed identifier` + ); + + const asString = validTree(); + asString.openmct[CHILD_KEY].identifier = CHILD_KEY; + expect(getImportTreeErrors(asString)).toContain( + `object "${CHILD_KEY}" has a missing or malformed identifier` + ); + }); + + it('rejects objects whose identifier does not match their key', () => { + const tree = validTree(); + tree.openmct[CHILD_KEY].identifier = { namespace: '', key: 'somebody-else' }; + + expect(getImportTreeErrors(tree)).toContain( + `object "${CHILD_KEY}" has an identifier that does not match its key` + ); + }); + + it('rejects objects with a missing or malformed type', () => { + const missing = validTree(); + delete missing.openmct[CHILD_KEY].type; + expect(getImportTreeErrors(missing)).toContain( + `object "${CHILD_KEY}" has a missing or malformed type` + ); + + ['hover', + embeds: [] + }); + + const html = renderedEntryHtml(); + expect(html).not.toContain('hover'); + expect(window.__xss).toBeUndefined(); + }); + + it('strips images and iframes that could load remote content', async () => { + await render({ + id: 'e1', + text: '', + embeds: [] + }); + + const html = renderedEntryHtml(); + expect(html).not.toContain(' { + await render({ + id: 'e1', + text: `js data rel`, + embeds: [] + }); + + const html = renderedEntryHtml(); + expect(html).not.toContain(SCRIPT_SCHEME); + expect(html).not.toContain('data:'); + expect(html).not.toContain('href="//'); + }); + }); + + describe('Markdown links', () => { + it('renders links to allowlisted hosts with noopener and noreferrer', async () => { + await render({ id: 'e1', text: '[docs](https://docs.example.com/page)', embeds: [] }); + + const anchor = element.querySelector('.c-ne__text a'); + expect(anchor).not.toBeNull(); + expect(anchor.getAttribute('href')).toBe('https://docs.example.com/page'); + expect(anchor.getAttribute('target')).toBe('_blank'); + expect(anchor.getAttribute('rel')).toBe('noopener noreferrer'); + expect(anchor.textContent).toBe('docs'); + }); + + it('preserves the link target exactly as written for bare and query-string URLs', async () => { + await render( + { + id: 'e1', + text: 'See https://www.example.com and https://www.example.com?bad= please', + embeds: [] + }, + ['example.com'] + ); + + const anchors = Array.from(element.querySelectorAll('.c-ne__text a')); + expect(anchors.map((anchor) => anchor.getAttribute('href'))).toEqual([ + 'https://www.example.com', + 'https://www.example.com?bad=' + ]); + }); + + it('does not turn links to non-allowlisted hosts into anchors', async () => { + await render({ id: 'e1', text: '[evil](https://attacker.example/x)', embeds: [] }); + + expect(element.querySelector('.c-ne__text a')).toBeNull(); + expect(renderedEntryHtml()).toContain('evil'); + }); + + it('does not treat a host that merely ends with the allowlisted string as allowlisted', async () => { + await render({ id: 'e1', text: '[evil](https://notexample.com/x)', embeds: [] }); + + expect(element.querySelector('.c-ne__text a')).toBeNull(); + }); + + it('does not render javascript: links even when the whitelist is permissive', async () => { + await render({ id: 'e1', text: `[js](${SCRIPT_SCHEME}alert(1))`, embeds: [] }, ['']); + + expect(element.querySelector('.c-ne__text a')).toBeNull(); + expect(renderedEntryHtml()).not.toContain(SCRIPT_SCHEME); + }); + + it('escapes markup in link text and link targets', async () => { + await render({ + id: 'e1', + text: '[](https://example.com/"onmouseover="alert(1))', + embeds: [] + }); + + const html = renderedEntryHtml(); + const anchor = element.querySelector('.c-ne__text a'); + expect(element.querySelector('.c-ne__text img')).toBeNull(); + expect(anchor.textContent).toBe(''); + expect(anchor.getAttribute('onmouseover')).toBeNull(); + expect(anchor.getAttribute('href')).toBe('https://example.com/"onmouseover="alert(1)'); + expect(anchor.attributes.length).toBe(4); + expect(html).not.toContain('onmouseover="alert'); + }); + + it('validateLink returns escaped text for unparsable URLs', () => { + const validateLink = NotebookEntry.methods.validateLink.bind({ urlWhitelist: ['a.b'] }); + + expect(validateLink({ href: 'not a url', text: 'x' })).toBe('<b>x</b>'); + }); + }); + + describe('failure notifications', () => { + it('does not expose raw error details when an embedded image cannot be added', async () => { + await render({ id: 'e1', text: 'text', embeds: [] }); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + + const rawError = new Error('Failed to fetch https://internal.example/secret.png'); + const dataTransfer = { + getData: (type) => (type === 'URL' ? 'https://internal.example/secret.png' : ''), + files: [] + }; + spyOn(window, 'fetch').and.returnValue(Promise.reject(rawError)); + + await component.dropOnEntry({ + preventDefault: () => {}, + stopImmediatePropagation: () => {}, + dataTransfer + }); + + expect(openmct.notifications.error).toHaveBeenCalledOnceWith('Unable to add image.'); + expect(console.error).toHaveBeenCalledWith('Problem embedding remote image', rawError); + }); + }); +}); diff --git a/src/plugins/notebook/utils/notebook-entries.js b/src/plugins/notebook/utils/notebook-entries.js index ea7d9d7639..7d685879e5 100644 --- a/src/plugins/notebook/utils/notebook-entries.js +++ b/src/plugins/notebook/utils/notebook-entries.js @@ -121,16 +121,28 @@ export function getHistoricLinkInFixedMode(openmct, bounds, historicLink) { return params.join('&'); } +/** + * Resolves with the new embed, or with `undefined` after notifying the operator + * when the image could not be read, stored or reduced to a thumbnail. + */ export function createNewImageEmbed(image, openmct, imageName = '') { return new Promise((resolve) => { const reader = new FileReader(); - reader.onloadend = async () => { + function fail(error) { + console.error(`${error?.message} - unable to embed image ${imageName}`, error); + openmct.notifications.error('Unable to embed image.'); + resolve(undefined); + } + reader.onerror = () => fail(reader.error); + reader.onload = async () => { + const blobUrl = URL.createObjectURL(image); try { const base64Data = reader.result; - const blobUrl = URL.createObjectURL(image); + // build the thumbnail first so a bad image never leaves an unreferenced + // full-size image object in storage + const imageThumbnailURL = await getThumbnailURLFromImageUrl(blobUrl); const imageDomainObject = createNotebookImageDomainObject(base64Data); await saveNotebookImageDomainObject(openmct, imageDomainObject); - const imageThumbnailURL = await getThumbnailURLFromImageUrl(blobUrl); const snapshot = { fullSizeImageObjectIdentifier: imageDomainObject.identifier, @@ -151,12 +163,17 @@ export function createNewImageEmbed(image, openmct, imageName = '') { const createdEmbed = await createNewEmbed(embedMetaData, snapshot); resolve(createdEmbed); } catch (error) { - console.error(`${error.message} - unable to embed image ${imageName}`, error); - openmct.notifications.error(`${error.message} -- unable to embed image ${imageName}`); + fail(error); + } finally { + URL.revokeObjectURL(blobUrl); } }; - reader.readAsDataURL(image); + try { + reader.readAsDataURL(image); + } catch (error) { + fail(error); + } }); } @@ -239,10 +256,42 @@ export async function addNotebookEntry( addDefaultClass(domainObject, openmct); mutateObject(openmct, domainObject, 'configuration.entries', newEntries); + openmct.audit?.record({ + action: 'notebook.entry.create', + outcome: 'success', + target: domainObject.identifier, + details: { + entryId: id, + sectionId: notebookStorage.defaultSectionId ?? null, + pageId: notebookStorage.defaultPageId ?? null, + embedCount: embedsNormalized?.length ?? 0 + } + }); return id; } +/** + * Emits an audit record for the removal of a single notebook entry. + * @param {import('openmct').OpenMCT} openmct + * @param {import('openmct').DomainObject} domainObject the notebook + * @param {string} entryId + * @param {{id: string} | undefined} section + * @param {{id: string} | undefined} page + */ +export function auditNotebookEntryDeletion(openmct, domainObject, entryId, section, page) { + openmct.audit?.record({ + action: 'notebook.entry.delete', + outcome: 'success', + target: domainObject.identifier, + details: { + entryId, + sectionId: section?.id ?? null, + pageId: page?.id ?? null + } + }); +} + export function getNotebookEntries(domainObject, selectedSection, selectedPage) { if (!domainObject || !selectedSection || !selectedPage || !domainObject.configuration) { return; @@ -294,7 +343,17 @@ export function deleteNotebookEntries(openmct, domainObject, selectedSection, se // Delete entire section if (!selectedPage) { + const sectionEntryCount = Object.values(entries[selectedSection.id] ?? {}).reduce( + (count, pageEntries) => count + (Array.isArray(pageEntries) ? pageEntries.length : 0), + 0 + ); delete entries[selectedSection.id]; + openmct.audit?.record({ + action: 'notebook.entry.delete', + outcome: 'success', + target: domainObject.identifier, + details: { sectionId: selectedSection.id, pageId: null, entryCount: sectionEntryCount } + }); return; } @@ -304,9 +363,18 @@ export function deleteNotebookEntries(openmct, domainObject, selectedSection, se return; } + const pageEntryCount = Array.isArray(section[selectedPage.id]) + ? section[selectedPage.id].length + : 0; delete entries[selectedSection.id][selectedPage.id]; mutateObject(openmct, domainObject, 'configuration.entries', entries); + openmct.audit?.record({ + action: 'notebook.entry.delete', + outcome: 'success', + target: domainObject.identifier, + details: { sectionId: selectedSection.id, pageId: selectedPage.id, entryCount: pageEntryCount } + }); } export function mutateObject(openmct, object, key, value) { diff --git a/src/plugins/notebook/utils/notebook-entriesSpec.js b/src/plugins/notebook/utils/notebook-entriesSpec.js index 4d693907c7..c6a93a09ef 100644 --- a/src/plugins/notebook/utils/notebook-entriesSpec.js +++ b/src/plugins/notebook/utils/notebook-entriesSpec.js @@ -19,7 +19,7 @@ * this source code distribution or the Licensing information page available * at runtime from the About dialog for additional information. *****************************************************************************/ -import { createOpenMct, resetApplicationState } from 'utils/testing'; +import { collectAuditRecords, createOpenMct, resetApplicationState } from 'utils/testing'; import * as NotebookEntries from './notebook-entries.js'; @@ -232,4 +232,146 @@ describe('Notebook Entries:', () => { expect(afterEntries).toEqual(undefined); }); + + describe('createNewImageEmbed', () => { + // 1x1 transparent PNG + const PNG_BYTES = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==' + ), + (char) => char.charCodeAt(0) + ); + + beforeEach(() => { + spyOn(URL, 'revokeObjectURL').and.callThrough(); + }); + + it('settles with undefined and shows a generic message when persistence fails', async () => { + const rawError = new Error('CouchDB at 10.0.0.5:5984 returned 500'); + openmct.objects.save = () => Promise.reject(rawError); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + const image = new Blob([PNG_BYTES], { type: 'image/png' }); + + const embed = await NotebookEntries.createNewImageEmbed(image, openmct, 'x.png'); + + expect(embed).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith(jasmine.stringContaining('x.png'), rawError); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith('Unable to embed image.'); + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); + }); + + it('does not store the image when the thumbnail cannot be generated', async () => { + openmct.objects.save = jasmine.createSpy('save').and.returnValue(Promise.resolve(true)); + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + const image = new Blob(['not-really-an-image'], { type: 'image/png' }); + + const embed = await NotebookEntries.createNewImageEmbed(image, openmct, 'z.png'); + + expect(embed).toBeUndefined(); + expect(openmct.objects.save).not.toHaveBeenCalled(); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith('Unable to embed image.'); + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); + }); + + it('stores the image and resolves with an embed when the image is valid', async () => { + openmct.objects.save = jasmine.createSpy('save').and.returnValue(Promise.resolve(true)); + const image = new Blob([PNG_BYTES], { type: 'image/png' }); + + const embed = await NotebookEntries.createNewImageEmbed(image, openmct, 'ok.png'); + + expect(openmct.objects.save).toHaveBeenCalledTimes(1); + expect(embed.snapshot.fullSizeImageObjectIdentifier).toEqual( + openmct.objects.save.calls.argsFor(0)[0].identifier + ); + expect(embed.snapshot.thumbnailImage.src).toMatch(/^data:image\/png/); + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(1); + }); + + it('settles with undefined when the image cannot be read', async () => { + spyOn(console, 'error'); + spyOn(openmct.notifications, 'error'); + + const embed = await NotebookEntries.createNewImageEmbed('not a blob', openmct, 'y.png'); + + expect(embed).toBeUndefined(); + expect(openmct.notifications.error).toHaveBeenCalledOnceWith('Unable to embed image.'); + }); + }); + + describe('audit records', () => { + let audit; + + beforeEach(() => { + audit = collectAuditRecords(openmct); + }); + + afterEach(() => { + audit.stop(); + }); + + it('addNotebookEntry emits a notebook.entry.create record', async () => { + const id = await NotebookEntries.addNotebookEntry( + openmct, + notebookDomainObject, + notebookStorage + ); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('notebook.entry.create'); + expect(auditRecords[0].outcome).toBe('success'); + expect(auditRecords[0].target).toBe('notebook'); + expect(auditRecords[0].details).toEqual({ + entryId: id, + sectionId: selectedSection.id, + pageId: selectedPage.id, + embedCount: 0 + }); + }); + + it('auditNotebookEntryDeletion emits a notebook.entry.delete record for one entry', async () => { + NotebookEntries.auditNotebookEntryDeletion( + openmct, + notebookDomainObject, + 'entry-1', + selectedSection, + selectedPage + ); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('notebook.entry.delete'); + expect(auditRecords[0].target).toBe('notebook'); + expect(auditRecords[0].details).toEqual({ + entryId: 'entry-1', + sectionId: selectedSection.id, + pageId: selectedPage.id + }); + }); + + it('deleteNotebookEntries emits a notebook.entry.delete record with the removed count', async () => { + await NotebookEntries.addNotebookEntry(openmct, notebookDomainObject, notebookStorage); + await NotebookEntries.addNotebookEntry(openmct, notebookDomainObject, notebookStorage); + await audit.waitFor(2); + audit.clear(); + + NotebookEntries.deleteNotebookEntries( + openmct, + notebookDomainObject, + selectedSection, + selectedPage + ); + const auditRecords = await audit.waitFor(1); + + expect(auditRecords.length).toBe(1); + expect(auditRecords[0].action).toBe('notebook.entry.delete'); + expect(auditRecords[0].details).toEqual({ + sectionId: selectedSection.id, + pageId: selectedPage.id, + entryCount: 2 + }); + }); + }); }); diff --git a/src/plugins/notebook/utils/notebook-image.js b/src/plugins/notebook/utils/notebook-image.js index 224090bc60..98c15f77a2 100644 --- a/src/plugins/notebook/utils/notebook-image.js +++ b/src/plugins/notebook/utils/notebook-image.js @@ -34,7 +34,7 @@ export function getThumbnailURLFromCanvas(canvas, size = DEFAULT_SIZE) { } export function getThumbnailURLFromImageUrl(imageUrl, size = DEFAULT_SIZE) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const image = new Image(); const canvas = document.createElement('canvas'); @@ -45,6 +45,9 @@ export function getThumbnailURLFromImageUrl(imageUrl, size = DEFAULT_SIZE) { canvas.getContext('2d').drawImage(image, 0, 0, size.width, size.height); resolve(canvas.toDataURL('image/png')); }; + image.onerror = function () { + reject(new Error('Unable to load image for thumbnail')); + }; image.src = imageUrl; }); @@ -69,7 +72,7 @@ export async function updateNotebookImageDomainObject(openmct, identifier, fullS openmct.objects.endTransaction(); } catch (error) { console.error(`${error.message} -- unable to save image`, error); - openmct.notifications.error(`${error.message} -- unable to save image`); + openmct.notifications.error('Unable to save image.'); } } diff --git a/src/plugins/persistence/couch/CouchObjectProvider.js b/src/plugins/persistence/couch/CouchObjectProvider.js index 71dfcca5a0..5411aa57b0 100644 --- a/src/plugins/persistence/couch/CouchObjectProvider.js +++ b/src/plugins/persistence/couch/CouchObjectProvider.js @@ -230,18 +230,33 @@ class CouchObjectProvider { // Network error, CouchDB unreachable. if (response === null) { this.indicator?.setIndicatorToState(DISCONNECTED); - console.error(error.message); + console.error('CouchDB request failed (no response):', method, subPath, error); - throw new Error(`CouchDB Error - No response"`); + throw new this.openmct.objects.errors.Persistence( + 'The object store could not be reached.', + { provider: 'couchdb', operation: method, cause: error } + ); } else { - if (body?.model && isNotebookOrAnnotationType(body.model)) { - // warn since we handle conflicts for notebooks - console.warn(error.message); - } else { - console.error(error.message); + const { Conflict, Persistence } = this.openmct.objects.errors; + if (error instanceof Conflict || error instanceof Persistence) { + if (body?.model && isNotebookOrAnnotationType(body.model)) { + // warn since we handle conflicts for notebooks + console.warn(error.message); + } else { + console.error(error.message); + } + + throw error; } - throw error; + // e.g. a malformed response body; keep the raw details in the log + console.error('CouchDB response could not be processed:', method, subPath, error); + + throw new Persistence('The object store returned an unexpected response.', { + provider: 'couchdb', + operation: method, + cause: error + }); } } } @@ -257,11 +272,21 @@ class CouchObjectProvider { const objectName = JSON.parse(fetchOptions.body)?.model?.name; throw new this.openmct.objects.errors.Conflict(`Conflict persisting "${objectName}"`); } else if (status >= CouchObjectProvider.HTTP_BAD_REQUEST) { - if (!json.error || !json.reason) { - throw new Error(`CouchDB Error ${status}`); - } - - throw new Error(`CouchDB Error ${status}: "${json.error} - ${json.reason}"`); + // server-supplied error/reason strings are recorded for diagnostics only + // and are deliberately kept off the thrown error so they are never shown + // to an operator + console.error( + `CouchDB request failed (HTTP ${status}):`, + fetchOptions.method, + json?.error ?? '', + json?.reason ?? '' + ); + + throw new this.openmct.objects.errors.Persistence('The object store rejected the request.', { + provider: 'couchdb', + operation: fetchOptions.method, + status + }); } } diff --git a/src/plugins/persistence/couch/pluginSpec.js b/src/plugins/persistence/couch/pluginSpec.js index a46f67872a..0fbb9c1b53 100644 --- a/src/plugins/persistence/couch/pluginSpec.js +++ b/src/plugins/persistence/couch/pluginSpec.js @@ -191,6 +191,93 @@ describe('the plugin', () => { window.EventSource = cachedEventSource; }); }); + describe('normalizes failures into generic persistence errors', () => { + beforeEach(() => { + spyOn(console, 'error'); + }); + + async function requestError(method, body) { + try { + await provider.request('some-value', method, body); + } catch (error) { + return error; + } + + throw new Error('expected the request to fail'); + } + + it('when the server cannot be reached', async () => { + const rawError = new TypeError('Failed to fetch http://internal-host:5984/openmct'); + fetch.and.throwError(rawError); + + const error = await requestError('GET'); + + expect(error).toBeInstanceOf(openmct.objects.errors.Persistence); + expect(error.message).toBe('The object store could not be reached.'); + expect(error.message).not.toContain('internal-host'); + expect(error.provider).toBe('couchdb'); + expect(error.operation).toBe('GET'); + expect(error.cause).toBe(rawError); + expect(console.error).toHaveBeenCalled(); + }); + + it('when the server responds with an error status', async () => { + fetch.and.returnValue( + Promise.resolve({ + status: 500, + json: () => ({ + error: 'internal_server_error', + reason: 'Database file /var/lib/couchdb/openmct.couch is corrupt' + }) + }) + ); + + const error = await requestError('GET'); + + expect(error).toBeInstanceOf(openmct.objects.errors.Persistence); + expect(error.message).toBe('The object store rejected the request.'); + expect(error.message).not.toContain('/var/lib/couchdb'); + expect(error.message).not.toContain('internal_server_error'); + expect(error.status).toBe(500); + expect(console.error).toHaveBeenCalledWith( + 'CouchDB request failed (HTTP 500):', + 'GET', + 'internal_server_error', + 'Database file /var/lib/couchdb/openmct.couch is corrupt' + ); + }); + + it('when the response body cannot be parsed', async () => { + const rawError = new SyntaxError('Unexpected token < in JSON at position 0'); + fetch.and.returnValue( + Promise.resolve({ + status: 200, + json: () => Promise.reject(rawError) + }) + ); + + const error = await requestError('GET'); + + expect(error).toBeInstanceOf(openmct.objects.errors.Persistence); + expect(error.message).toBe('The object store returned an unexpected response.'); + expect(error.cause).toBe(rawError); + }); + + it('while preserving conflict errors so callers can resolve them', async () => { + fetch.and.returnValue( + Promise.resolve({ + status: 409, + json: () => ({ error: 'conflict', reason: 'Document update conflict.' }) + }) + ); + + const error = await requestError('PUT', { model: { name: 'Some object', type: 'folder' } }); + + expect(error).toBeInstanceOf(openmct.objects.errors.Conflict); + expect(error).not.toBeInstanceOf(openmct.objects.errors.Persistence); + }); + }); + describe('batches requests', () => { let mockPromise; beforeEach(() => { diff --git a/src/utils/testing.js b/src/utils/testing.js index 5bc40949f0..631f7b3eca 100644 --- a/src/utils/testing.js +++ b/src/utils/testing.js @@ -54,6 +54,40 @@ export function createOpenMct(timeSystemOptions = DEFAULT_TIME_OPTIONS) { return openmct; } +/** + * Subscribes to the audit service of the given openmct instance and collects + * every record it emits. `waitFor(count)` resolves once at least `count` + * records have been collected, which avoids relying on timers (specs may + * install a mock clock) to flush the asynchronous audit pipeline. + */ +export function collectAuditRecords(openmct) { + const records = []; + const waiters = []; + const stop = openmct.audit.addProvider({ + record(auditRecord) { + records.push(auditRecord); + waiters + .filter((waiter) => records.length >= waiter.count) + .forEach((waiter) => waiter.resolve(records)); + } + }); + + return { + records, + clear() { + records.length = 0; + }, + waitFor(count = 1) { + if (records.length >= count) { + return Promise.resolve(records); + } + + return new Promise((resolve) => waiters.push({ count, resolve })); + }, + stop + }; +} + export function createMouseEvent(eventName) { return new MouseEvent(eventName, { bubbles: true, diff --git a/src/utils/textHighlight/TextHighlight.vue b/src/utils/textHighlight/TextHighlight.vue index e28d47b6d0..7bdd00c02f 100644 --- a/src/utils/textHighlight/TextHighlight.vue +++ b/src/utils/textHighlight/TextHighlight.vue @@ -45,19 +45,39 @@ export default { } }, computed: { + /** + * Wraps matches of `highlight` found in the text content of `text` (which + * is expected to be already-sanitized HTML) in a span. The highlight term is + * treated as a literal string, and the wrapped content is the matched text + * itself, so no markup from the search term reaches the rendered HTML. + */ highlightedText() { const highlight = this.highlight; - const normalCharsRegex = /^[^A-Za-z0-9]+$/g; - - const newHighLight = normalCharsRegex.test(highlight) ? `\\${highlight}` : highlight; - - const highlightRegex = new RegExp(`(?]*)(${newHighLight})`, 'gi'); + if (!highlight) { + return this.text; + } - const replacement = `${highlight}`; + // text content inside sanitized HTML is entity-encoded, so encode the + // search term the same way before looking for it + const term = escapeRegExp(escapeHtml(highlight)); + const highlightRegex = new RegExp(`(?]*)(${term})`, 'gi'); + const replacement = `$1`; return this.text.replace(highlightRegex, replacement); } } }; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/utils/textHighlight/TextHighlightSpec.js b/src/utils/textHighlight/TextHighlightSpec.js new file mode 100644 index 0000000000..35085cdfe1 --- /dev/null +++ b/src/utils/textHighlight/TextHighlightSpec.js @@ -0,0 +1,112 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import mount from 'utils/mount'; +import { h, nextTick } from 'vue'; + +import TextHighlight from './TextHighlight.vue'; + +describe('TextHighlight', () => { + let element; + let destroy; + + async function render(props) { + element = document.createElement('div'); + const mounted = mount({ render: () => h(TextHighlight, props) }, { element }); + destroy = mounted.destroy; + await nextTick(); + + return element.firstElementChild; + } + + afterEach(() => { + if (destroy) { + destroy(); + destroy = undefined; + } + }); + + it('wraps case-insensitive matches of the highlight term in a span', async () => { + const span = await render({ text: '

Alpha alpha beta

', highlight: 'alpha' }); + + expect(span.querySelectorAll('span.highlight').length).toBe(2); + expect(span.textContent).toBe('Alpha alpha beta'); + }); + + it('returns the text unchanged when there is no highlight term', async () => { + const span = await render({ text: '

Alpha

', highlight: '' }); + + expect(span.innerHTML).toBe('

Alpha

'); + }); + + it('does not inject markup from the highlight term', async () => { + const span = await render({ + text: '

hello <img>

', + highlight: '' + }); + + expect(span.querySelector('img')).toBeNull(); + expect(window.__xssHighlight).toBeUndefined(); + expect(span.textContent).toBe('hello '); + }); + + it('treats regular expression metacharacters in the term literally', async () => { + const span = await render({ + text: '

cost: $5.00 (approx.)

', + highlight: '$5.00 (approx.)' + }); + + expect(span.querySelectorAll('span.highlight').length).toBe(1); + expect(span.querySelector('span.highlight').textContent).toBe('$5.00 (approx.)'); + }); + + it('does not match inside tag attributes', async () => { + const span = await render({ + text: 'hyperlink', + highlight: 'hyperlink' + }); + + const anchor = span.querySelector('a'); + expect(anchor.getAttribute('class')).toBe('c-hyperlink'); + expect(anchor.getAttribute('href')).toBe('https://example.com/hyperlink'); + expect(anchor.querySelectorAll('span.highlight').length).toBe(1); + }); + + it('escapes the highlight class before rendering it as an attribute', async () => { + const span = await render({ + text: '

alpha

', + highlight: 'alpha', + highlightClass: 'x" onmouseover="window.__xssClass = true' + }); + + const highlighted = span.querySelector('p span'); + expect(highlighted.getAttribute('onmouseover')).toBeNull(); + expect(window.__xssClass).toBeUndefined(); + }); + + it('matches entity-encoded text such as ampersands', async () => { + const span = await render({ text: '

Tom & Jerry

', highlight: '&' }); + + expect(span.querySelectorAll('span.highlight').length).toBe(1); + expect(span.querySelector('span.highlight').textContent).toBe('&'); + }); +});