Skip to content

Commit b5bda04

Browse files
mLucamzl2fejkowalleck
authored
feat: guarantee correct URL (#996)
Serialization/normalization guarantees valid URI values according to JSON/XML specification fixes #992 --------- Signed-off-by: mzl2fe <[email protected]> Signed-off-by: Jan Kowalleck <[email protected]> Co-authored-by: mzl2fe <[email protected]> Co-authored-by: Jan Kowalleck <[email protected]>
1 parent a31ae7c commit b5bda04

21 files changed

+1665
-19
lines changed

HISTORY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
## unreleased
66

7+
* Changed
8+
* Serialization/normalization guarantees valid URI values according to JSON/XML specification ([#992] via [#996])
9+
10+
[#992]: https://github.com/CycloneDX/cyclonedx-javascript-library/issues/992
11+
[#996]: https://github.com/CycloneDX/cyclonedx-javascript-library/pull/996
12+
713
## 6.1.3 -- 2023-12-09
814

915
* Fixed

src/_helpers/uri.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*!
2+
This file is part of CycloneDX JavaScript Library.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
16+
SPDX-License-Identifier: Apache-2.0
17+
Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
20+
const escapeMap: Readonly<Record<string, string>> = Object.freeze({
21+
' ': '%20',
22+
'[': '%5B',
23+
']': '%5D',
24+
'<': '%3C',
25+
'>': '%3E',
26+
'{': '%7B',
27+
'}': '%7D'
28+
})
29+
30+
/**
31+
* Make a string valid to
32+
* - XML::anyURI spec.
33+
* - JSON::iri-reference spec.
34+
*
35+
* BEST EFFORT IMPLEMENTATION
36+
*
37+
* @see http://www.w3.org/TR/xmlschema-2/#anyURI
38+
* @see http://www.datypic.com/sc/xsd/t-xsd_anyURI.html
39+
* @see https://datatracker.ietf.org/doc/html/rfc2396
40+
* @see https://datatracker.ietf.org/doc/html/rfc3987
41+
*/
42+
export function escapeUri<T extends (string | undefined)> (value: T): T {
43+
if (value === undefined) {
44+
return value
45+
}
46+
for (const [s, r] of Object.entries(escapeMap)) {
47+
/* @ts-expect-error -- TS does not properly detect that value is to be forced as string, here */
48+
value = value.replace(s, r)
49+
}
50+
return value
51+
}

src/serialize/json/normalize.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { isNotUndefined } from '../../_helpers/notUndefined'
2121
import type { SortableIterable } from '../../_helpers/sortable'
2222
import type { Stringable } from '../../_helpers/stringable'
2323
import { treeIteratorSymbol } from '../../_helpers/tree'
24+
import { escapeUri } from '../../_helpers/uri'
2425
import * as Models from '../../models'
2526
import { isSupportedSpdxId } from '../../spdx'
2627
import { Version as SpecVersion } from '../../spec'
@@ -311,8 +312,10 @@ export class OrganizationalContactNormalizer extends BaseJsonNormalizer<Models.O
311312

312313
export class OrganizationalEntityNormalizer extends BaseJsonNormalizer<Models.OrganizationalEntity> {
313314
normalize (data: Models.OrganizationalEntity, options: NormalizerOptions): Normalized.OrganizationalEntity {
314-
const urls = normalizeStringableIter(data.url, options)
315-
.filter(JsonSchema.isIriReference)
315+
const urls = normalizeStringableIter(
316+
Array.from(data.url, (s) => escapeUri(s.toString())),
317+
options
318+
).filter(JsonSchema.isIriReference)
316319
return {
317320
name: data.name || undefined,
318321
url: urls.length > 0
@@ -438,7 +441,7 @@ export class LicenseNormalizer extends BaseJsonNormalizer<Models.License> {
438441
}
439442

440443
#normalizeNamedLicense (data: Models.NamedLicense, options: NormalizerOptions): Normalized.NamedLicense {
441-
const url = data.url?.toString()
444+
const url = escapeUri(data.url?.toString())
442445
return {
443446
license: {
444447
name: data.name,
@@ -453,13 +456,16 @@ export class LicenseNormalizer extends BaseJsonNormalizer<Models.License> {
453456
}
454457

455458
#normalizeSpdxLicense (data: Models.SpdxLicense, options: NormalizerOptions): Normalized.SpdxLicense {
459+
const url = escapeUri(data.url?.toString())
456460
return {
457461
license: {
458462
id: data.id,
459463
text: data.text === undefined
460464
? undefined
461465
: this._factory.makeForAttachment().normalize(data.text, options),
462-
url: data.url?.toString()
466+
url: JsonSchema.isIriReference(url)
467+
? url
468+
: undefined
463469
}
464470
}
465471
}
@@ -493,7 +499,7 @@ export class LicenseNormalizer extends BaseJsonNormalizer<Models.License> {
493499

494500
export class SWIDNormalizer extends BaseJsonNormalizer<Models.SWID> {
495501
normalize (data: Models.SWID, options: NormalizerOptions): Normalized.SWID {
496-
const url = data.url?.toString()
502+
const url = escapeUri(data.url?.toString())
497503
return {
498504
tagId: data.tagId,
499505
name: data.name,
@@ -514,7 +520,7 @@ export class ExternalReferenceNormalizer extends BaseJsonNormalizer<Models.Exter
514520
normalize (data: Models.ExternalReference, options: NormalizerOptions): Normalized.ExternalReference | undefined {
515521
return this._factory.spec.supportsExternalReferenceType(data.type)
516522
? {
517-
url: data.url.toString(),
523+
url: escapeUri(data.url.toString()),
518524
type: data.type,
519525
hashes: this._factory.spec.supportsExternalReferenceHashes && data.hashes.size > 0
520526
? this._factory.makeForHash().normalizeIterable(data.hashes, options)
@@ -726,7 +732,7 @@ export class VulnerabilityRatingNormalizer extends BaseJsonNormalizer<Models.Vul
726732

727733
export class VulnerabilityAdvisoryNormalizer extends BaseJsonNormalizer<Models.Vulnerability.Advisory> {
728734
normalize (data: Models.Vulnerability.Advisory, options: NormalizerOptions): Normalized.Vulnerability.Advisory | undefined {
729-
const url = data.url.toString()
735+
const url = escapeUri(data.url.toString())
730736
if (!JsonSchema.isIriReference(url)) {
731737
// invalid value -> cannot render
732738
return undefined

src/serialize/xml/normalize.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { isNotUndefined } from '../../_helpers/notUndefined'
2121
import type { SortableIterable } from '../../_helpers/sortable'
2222
import type { Stringable } from '../../_helpers/stringable'
2323
import { treeIteratorSymbol } from '../../_helpers/tree'
24+
import { escapeUri } from '../../_helpers/uri'
2425
import * as Models from '../../models'
2526
import { isSupportedSpdxId } from '../../spdx'
2627
import { Version as SpecVersion } from '../../spec'
@@ -388,8 +389,10 @@ export class OrganizationalEntityNormalizer extends BaseXmlNormalizer<Models.Org
388389
name: elementName,
389390
children: [
390391
makeOptionalTextElement(data.name, 'name'),
391-
...makeTextElementIter(data.url, options, 'url')
392-
.filter(({ children: u }) => XmlSchema.isAnyURI(u)),
392+
...makeTextElementIter(Array.from(
393+
data.url, (s): string => escapeUri(s.toString())
394+
), options, 'url'
395+
).filter(({ children: u }) => XmlSchema.isAnyURI(u)),
393396
...this._factory.makeForOrganizationalContact().normalizeIterable(data.contact, options, 'contact')
394397
].filter(isNotUndefined)
395398
}
@@ -554,7 +557,7 @@ export class LicenseNormalizer extends BaseXmlNormalizer<Models.License> {
554557
}
555558

556559
#normalizeNamedLicense (data: Models.NamedLicense, options: NormalizerOptions): SimpleXml.Element {
557-
const url = data.url?.toString()
560+
const url = escapeUri(data.url?.toString())
558561
return {
559562
type: 'element',
560563
name: 'license',
@@ -571,7 +574,7 @@ export class LicenseNormalizer extends BaseXmlNormalizer<Models.License> {
571574
}
572575

573576
#normalizeSpdxLicense (data: Models.SpdxLicense, options: NormalizerOptions): SimpleXml.Element {
574-
const url = data.url?.toString()
577+
const url = escapeUri(data.url?.toString())
575578
return {
576579
type: 'element',
577580
name: 'license',
@@ -614,7 +617,7 @@ export class LicenseNormalizer extends BaseXmlNormalizer<Models.License> {
614617

615618
export class SWIDNormalizer extends BaseXmlNormalizer<Models.SWID> {
616619
normalize (data: Models.SWID, options: NormalizerOptions, elementName: string): SimpleXml.Element {
617-
const url = data.url?.toString()
620+
const url = escapeUri(data.url?.toString())
618621
return {
619622
type: 'element',
620623
name: elementName,
@@ -641,7 +644,7 @@ export class SWIDNormalizer extends BaseXmlNormalizer<Models.SWID> {
641644

642645
export class ExternalReferenceNormalizer extends BaseXmlNormalizer<Models.ExternalReference> {
643646
normalize (data: Models.ExternalReference, options: NormalizerOptions, elementName: string): SimpleXml.Element | undefined {
644-
const url = data.url.toString()
647+
const url = escapeUri(data.url.toString())
645648
const hashes: SimpleXml.Element | undefined = this._factory.spec.supportsExternalReferenceHashes && data.hashes.size > 0
646649
? {
647650
type: 'element',
@@ -874,7 +877,7 @@ export class VulnerabilityNormalizer extends BaseXmlNormalizer<Models.Vulnerabil
874877

875878
export class VulnerabilitySourceNormalizer extends BaseXmlNormalizer<Models.Vulnerability.Source> {
876879
normalize (data: Models.Vulnerability.Source, options: NormalizerOptions, elementName: string): SimpleXml.Element {
877-
const url = data.url?.toString()
880+
const url = escapeUri(data.url?.toString())
878881
return {
879882
type: 'element',
880883
name: elementName,
@@ -940,7 +943,7 @@ export class VulnerabilityRatingNormalizer extends BaseXmlNormalizer<Models.Vuln
940943

941944
export class VulnerabilityAdvisoryNormalizer extends BaseXmlNormalizer<Models.Vulnerability.Advisory> {
942945
normalize (data: Models.Vulnerability.Advisory, options: NormalizerOptions, elementName: string): SimpleXml.Element | undefined {
943-
const url = data.url.toString()
946+
const url = escapeUri(data.url.toString())
944947
if (!XmlSchema.isAnyURI(url)) {
945948
// invalid value -> cannot render
946949
return undefined

tests/_data/models.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,6 @@ const { PackageURL } = require('packageurl-js')
2121

2222
const { Enums, Models, Types } = require('../../')
2323

24-
/* eslint-disable jsdoc/valid-types */
25-
26-
/* eslint-enable jsdoc/valid-types */
27-
2824
/**
2925
* @returns {Models.Bom}
3026
*/
@@ -268,6 +264,34 @@ module.exports.createComplexStructure = function () {
268264
)
269265
)
270266

267+
bom.components.add(
268+
new Models.Component(
269+
Enums.ComponentType.Library, 'component-with-unescaped-urls', {
270+
bomRef: 'component-with-unescaped-urls',
271+
externalReferences: new Models.ExternalReferenceRepository(
272+
[
273+
['encode anyUri: urn', 'urn:example:org'],
274+
['encode anyUri: https', 'https://example.org/p?k=v#f'],
275+
['encode anyUri: mailto', 'mailto:[email protected]'],
276+
['encode anyUri: relative path', '../foo/bar'],
277+
['encode anyUri: space', 'https://example.org/foo bar'],
278+
['encode anyUri: []', 'https://example.org/?bar[test]=baz'],
279+
['encode anyUri: <>', 'https://example.org/#<test>'],
280+
['encode anyUri: {}', 'https://example.org/#{test}'],
281+
['encode anyUri: non-ASCII', 'https://example.org/édition'],
282+
['encode anyUri: partially encoded', 'https://example.org/?bar[test%5D=baz']
283+
].map(
284+
([desc, uri]) => new Models.ExternalReference(
285+
uri, Enums.ExternalReferenceType.Other, {
286+
comment: desc
287+
}
288+
)
289+
)
290+
)
291+
}
292+
)
293+
)
294+
271295
const someVulnerableComponent = new Models.Component(
272296
Enums.ComponentType.Library,
273297
'component-with-vulnerabilities',

tests/_data/normalizeResults/json_sortedLists_spec1.2.json

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)