From ea0bff5eeb0534f0ca820d11bbe90c553af3cdba Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 13:26:38 -0400 Subject: [PATCH 1/6] fix(test): increase timeout for network-dependent purlExists tests The conda and docker purlExists tests make real HTTP requests to external registries. The conda test was timing out on CI at the default 10s limit. Increase timeout to 30s for both network-calling tests. --- test/purl-edge-cases.test.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/purl-edge-cases.test.mts b/test/purl-edge-cases.test.mts index bb9d2fe..361437d 100644 --- a/test/purl-edge-cases.test.mts +++ b/test/purl-edge-cases.test.mts @@ -2221,13 +2221,13 @@ describe('Edge cases and additional coverage', () => { const result = await purlExists(purl) // Network call may succeed or fail, but it should not return "Unsupported type" expect(result.error).not.toBe('Unsupported type: conda') - }) + }, 30_000) it('should dispatch docker type', async () => { const purl = PackageURL.fromString('pkg:docker/nginx@latest') const result = await purlExists(purl) expect(result.error).not.toBe('Unsupported type: docker') - }) + }, 30_000) }) describe('UrlConverter edge cases', () => { From f31072b5dce3833acad7c380bef901d64bc186ad Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 13:28:23 -0400 Subject: [PATCH 2/6] fix(test): reduce timeout to 15s --- test/purl-edge-cases.test.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/purl-edge-cases.test.mts b/test/purl-edge-cases.test.mts index 361437d..c440117 100644 --- a/test/purl-edge-cases.test.mts +++ b/test/purl-edge-cases.test.mts @@ -2221,13 +2221,13 @@ describe('Edge cases and additional coverage', () => { const result = await purlExists(purl) // Network call may succeed or fail, but it should not return "Unsupported type" expect(result.error).not.toBe('Unsupported type: conda') - }, 30_000) + }, 15_000) it('should dispatch docker type', async () => { const purl = PackageURL.fromString('pkg:docker/nginx@latest') const result = await purlExists(purl) expect(result.error).not.toBe('Unsupported type: docker') - }, 30_000) + }, 15_000) }) describe('UrlConverter edge cases', () => { From af0411ae2e8ca3c7aeca6aa3e2c6cbead99952d4 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 14:57:13 -0400 Subject: [PATCH 3/6] feat(security): add injection character validation to all purl type validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add containsInjectionCharacters() checks to all 28 per-type validators, rejecting shell/URL metacharacters (|, &, ;, `, $, <, >, (, ), {, }, #, \, space, tab, newline, CR) in name and namespace components. Previously only vscode-extension had these checks. Now every ecosystem type validates against injection characters while respecting the purl spec (which allows special characters in the generic type via percent-encoding — so checks are per-type, not in the base validator). - Enhanced 19 existing validators with injection checks - Added new validate functions to 6 types (docker, github, gitlab, bitbucket, hex, pypi) that previously only had normalize - Registered all new validators in purl-type.ts - Cocoapods: replaced \s regex with containsInjectionCharacters (subsumes whitespace + adds shell metachar detection) - npm/pub: already covered by URL-encoding and [a-z0-9_] checks - Version strings intentionally NOT checked (Python epoch ! and Maven space/& are legitimate) - 47 new tests covering all types --- src/purl-type.ts | 36 ++- src/purl-types/bazel.ts | 11 +- src/purl-types/bitbucket.ts | 36 ++- src/purl-types/cargo.ts | 21 +- src/purl-types/cocoapods.ts | 11 +- src/purl-types/conan.ts | 18 ++ src/purl-types/conda.ts | 22 +- src/purl-types/cpan.ts | 15 ++ src/purl-types/cran.ts | 21 +- src/purl-types/docker.ts | 28 ++- src/purl-types/gem.ts | 21 +- src/purl-types/github.ts | 34 ++- src/purl-types/gitlab.ts | 34 ++- src/purl-types/golang.ts | 20 +- src/purl-types/hex.ts | 32 ++- src/purl-types/julia.ts | 19 +- src/purl-types/maven.ts | 33 ++- src/purl-types/mlflow.ts | 20 +- src/purl-types/nuget.ts | 21 +- src/purl-types/oci.ts | 24 +- src/purl-types/opam.ts | 19 +- src/purl-types/otp.ts | 20 +- src/purl-types/pypi.ts | 16 ++ src/purl-types/swid.ts | 7 + src/purl-types/swift.ts | 36 ++- src/purl-types/yocto.ts | 20 +- test/injection-validation.test.mts | 370 +++++++++++++++++++++++++++++ test/purl-edge-cases.test.mts | 2 +- 28 files changed, 892 insertions(+), 75 deletions(-) create mode 100644 test/injection-validation.test.mts diff --git a/src/purl-type.ts b/src/purl-type.ts index c1730e0..b7a02f3 100644 --- a/src/purl-type.ts +++ b/src/purl-type.ts @@ -13,7 +13,10 @@ import { normalize as bazelNormalize, validate as bazelValidate, } from './purl-types/bazel.js' -import { normalize as bitbucketNormalize } from './purl-types/bitbucket.js' +import { + normalize as bitbucketNormalize, + validate as bitbucketValidate, +} from './purl-types/bitbucket.js' import { normalize as bitnamiNormalize } from './purl-types/bitnami.js' import { validate as cargoValidate } from './purl-types/cargo.js' import { validate as cocoaodsValidate } from './purl-types/cocoapods.js' @@ -26,13 +29,25 @@ import { import { validate as cpanValidate } from './purl-types/cpan.js' import { validate as cranValidate } from './purl-types/cran.js' import { normalize as debNormalize } from './purl-types/deb.js' -import { normalize as dockerNormalize } from './purl-types/docker.js' +import { + normalize as dockerNormalize, + validate as dockerValidate, +} from './purl-types/docker.js' import { validate as gemValidate } from './purl-types/gem.js' import { normalize as genericNormalize } from './purl-types/generic.js' -import { normalize as githubNormalize } from './purl-types/github.js' -import { normalize as gitlabNormalize } from './purl-types/gitlab.js' +import { + normalize as githubNormalize, + validate as githubValidate, +} from './purl-types/github.js' +import { + normalize as gitlabNormalize, + validate as gitlabValidate, +} from './purl-types/gitlab.js' import { validate as golangValidate } from './purl-types/golang.js' -import { normalize as hexNormalize } from './purl-types/hex.js' +import { + normalize as hexNormalize, + validate as hexValidate, +} from './purl-types/hex.js' import { normalize as huggingfaceNormalize } from './purl-types/huggingface.js' import { normalize as juliaNormalize, @@ -62,7 +77,10 @@ import { normalize as pubNormalize, validate as pubValidate, } from './purl-types/pub.js' -import { normalize as pypiNormalize } from './purl-types/pypi.js' +import { + normalize as pypiNormalize, + validate as pypiValidate, +} from './purl-types/pypi.js' import { normalize as qpkgNormalize } from './purl-types/qpkg.js' import { normalize as rpmNormalize } from './purl-types/rpm.js' import { normalize as socketNormalize } from './purl-types/socket.js' @@ -133,14 +151,19 @@ const PurlType = createHelpersNamespaceObject( }, validate: { bazel: bazelValidate, + bitbucket: bitbucketValidate, cargo: cargoValidate, cocoapods: cocoaodsValidate, conda: condaValidate, conan: conanValidate, cpan: cpanValidate, cran: cranValidate, + docker: dockerValidate, gem: gemValidate, + github: githubValidate, + gitlab: gitlabValidate, golang: golangValidate, + hex: hexValidate, julia: juliaValidate, maven: mavenValidate, mlflow: mlflowValidate, @@ -150,6 +173,7 @@ const PurlType = createHelpersNamespaceObject( opam: opamValidate, otp: otpValidate, pub: pubValidate, + pypi: pypiValidate, swift: swiftValidate, swid: swidValidate, 'vscode-extension': vscodeExtensionValidate, diff --git a/src/purl-types/bazel.ts b/src/purl-types/bazel.ts index 0e567fa..0fa8e71 100644 --- a/src/purl-types/bazel.ts +++ b/src/purl-types/bazel.ts @@ -7,7 +7,7 @@ */ import { PurlError } from '../error.js' -import { lowerName } from '../strings.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' interface PurlObject { name: string @@ -29,7 +29,8 @@ export function normalize(purl: PurlObject): PurlObject { /** * Validate Bazel package URL. - * Bazel packages must have a version (for reproducible builds). + * Bazel packages must have a version (for reproducible builds). Name must not + * contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { if (!purl.version || purl.version.length === 0) { @@ -38,5 +39,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('bazel "name" component contains illegal characters') + } + return false + } return true } diff --git a/src/purl-types/bitbucket.ts b/src/purl-types/bitbucket.ts index 3766f66..c7c95fd 100644 --- a/src/purl-types/bitbucket.ts +++ b/src/purl-types/bitbucket.ts @@ -1,9 +1,14 @@ /** - * @fileoverview Bitbucket PURL normalization. + * @fileoverview Bitbucket PURL normalization and validation. * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket */ -import { lowerName, lowerNamespace } from '../strings.js' +import { PurlError } from '../error.js' +import { + containsInjectionCharacters, + lowerName, + lowerNamespace, +} from '../strings.js' interface PurlObject { name: string @@ -23,3 +28,30 @@ export function normalize(purl: PurlObject): PurlObject { lowerName(purl) return purl } + +/** + * Validate Bitbucket package URL. + * Name and namespace must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'bitbucket "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError( + 'bitbucket "name" component contains illegal characters', + ) + } + return false + } + return true +} diff --git a/src/purl-types/cargo.ts b/src/purl-types/cargo.ts index 5089858..d74ada5 100644 --- a/src/purl-types/cargo.ts +++ b/src/purl-types/cargo.ts @@ -5,7 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -137,10 +139,21 @@ export async function cargoExists( /** * Validate Cargo package URL. - * Cargo packages must not have a namespace. + * Cargo packages must not have a namespace. Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('cargo', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('cargo', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('cargo "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/cocoapods.ts b/src/purl-types/cocoapods.ts index 3615042..1bf8de1 100644 --- a/src/purl-types/cocoapods.ts +++ b/src/purl-types/cocoapods.ts @@ -7,11 +7,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' import { - RegExpPrototypeTest, StringPrototypeCharCodeAt, StringPrototypeIncludes, ArrayPrototypeSome, } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -125,15 +125,16 @@ export async function cocoapodsExists( /** * Validate CocoaPods package URL. - * Name cannot contain whitespace, plus (+) character, or begin with a period (.). + * Name cannot contain injection or whitespace characters, plus (+) character, + * or begin with a period (.). */ export function validate(purl: PurlObject, throws: boolean): boolean { const { name } = purl - // Name cannot contain whitespace - if (RegExpPrototypeTest(/\s/, name)) { + // Name must not contain injection characters + if (containsInjectionCharacters(name)) { if (throws) { throw new PurlError( - 'cocoapods "name" component cannot contain whitespace', + 'cocoapods "name" component contains illegal characters', ) } return false diff --git a/src/purl-types/conan.ts b/src/purl-types/conan.ts index 2d2e502..c55e412 100644 --- a/src/purl-types/conan.ts +++ b/src/purl-types/conan.ts @@ -5,6 +5,7 @@ import { PurlError } from '../error.js' import { isNullishOrEmptyString } from '../lang.js' +import { containsInjectionCharacters } from '../strings.js' interface PurlObject { name: string @@ -38,5 +39,22 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'conan "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('conan "name" component contains illegal characters') + } + return false + } return true } diff --git a/src/purl-types/conda.ts b/src/purl-types/conda.ts index 2cd73b6..995bc3d 100644 --- a/src/purl-types/conda.ts +++ b/src/purl-types/conda.ts @@ -5,11 +5,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, StringPrototypeIncludes, } from '../primordials.js' -import { lowerName } from '../strings.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -34,12 +35,23 @@ export function normalize(purl: PurlObject): PurlObject { /** * Validate Conda package URL. - * Conda packages must not have a namespace. + * Conda packages must not have a namespace. Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('conda', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('conda', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('conda "name" component contains illegal characters') + } + return false + } + return true } /** diff --git a/src/purl-types/cpan.ts b/src/purl-types/cpan.ts index a8ce33f..3991f0e 100644 --- a/src/purl-types/cpan.ts +++ b/src/purl-types/cpan.ts @@ -10,6 +10,7 @@ import { StringPrototypeIncludes, StringPrototypeToUpperCase, } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -129,5 +130,19 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + if (typeof namespace === 'string' && containsInjectionCharacters(namespace)) { + if (throws) { + throw new PurlError( + 'cpan "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('cpan "name" component contains illegal characters') + } + return false + } return true } diff --git a/src/purl-types/cran.ts b/src/purl-types/cran.ts index ea39c3d..f74f199 100644 --- a/src/purl-types/cran.ts +++ b/src/purl-types/cran.ts @@ -5,10 +5,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, StringPrototypeIncludes, } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import { validateRequiredByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -119,10 +121,21 @@ export async function cranExists( /** * Validate CRAN package URL. - * CRAN packages require a version. + * CRAN packages require a version. Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateRequiredByType('cran', 'version', purl.version, { - throws, - }) + if ( + !validateRequiredByType('cran', 'version', purl.version, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('cran "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/docker.ts b/src/purl-types/docker.ts index aa7317c..73130e3 100644 --- a/src/purl-types/docker.ts +++ b/src/purl-types/docker.ts @@ -5,8 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' -import { lowerName } from '../strings.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -28,6 +29,31 @@ export function normalize(purl: PurlObject): PurlObject { return purl } +/** + * Validate Docker package URL. + * Name and namespace must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'docker "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('docker "name" component contains illegal characters') + } + return false + } + return true +} + /** * Check if a Docker image exists in Docker Hub. * diff --git a/src/purl-types/gem.ts b/src/purl-types/gem.ts index 28002eb..f04815c 100644 --- a/src/purl-types/gem.ts +++ b/src/purl-types/gem.ts @@ -5,7 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -131,10 +133,21 @@ export async function gemExists( /** * Validate RubyGem package URL. - * Gem packages must not have a namespace. + * Gem packages must not have a namespace. Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('gem', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('gem', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('gem "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/github.ts b/src/purl-types/github.ts index ce257c3..2fee641 100644 --- a/src/purl-types/github.ts +++ b/src/purl-types/github.ts @@ -1,9 +1,14 @@ /** - * @fileoverview GitHub PURL normalization. + * @fileoverview GitHub PURL normalization and validation. * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#github */ -import { lowerName, lowerNamespace } from '../strings.js' +import { PurlError } from '../error.js' +import { + containsInjectionCharacters, + lowerName, + lowerNamespace, +} from '../strings.js' interface PurlObject { name: string @@ -23,3 +28,28 @@ export function normalize(purl: PurlObject): PurlObject { lowerName(purl) return purl } + +/** + * Validate GitHub package URL. + * Name and namespace must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'github "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('github "name" component contains illegal characters') + } + return false + } + return true +} diff --git a/src/purl-types/gitlab.ts b/src/purl-types/gitlab.ts index 8a153b5..44a25b8 100644 --- a/src/purl-types/gitlab.ts +++ b/src/purl-types/gitlab.ts @@ -1,9 +1,14 @@ /** - * @fileoverview GitLab PURL normalization. + * @fileoverview GitLab PURL normalization and validation. * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#other-candidate-types-to-define */ -import { lowerName, lowerNamespace } from '../strings.js' +import { PurlError } from '../error.js' +import { + containsInjectionCharacters, + lowerName, + lowerNamespace, +} from '../strings.js' interface PurlObject { name: string @@ -23,3 +28,28 @@ export function normalize(purl: PurlObject): PurlObject { lowerName(purl) return purl } + +/** + * Validate GitLab package URL. + * Name and namespace must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'gitlab "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('gitlab "name" component contains illegal characters') + } + return false + } + return true +} diff --git a/src/purl-types/golang.ts b/src/purl-types/golang.ts index 0b40490..1c988a8 100644 --- a/src/purl-types/golang.ts +++ b/src/purl-types/golang.ts @@ -40,7 +40,7 @@ import { StringPrototypeSplit, StringPrototypeToLowerCase, } from '../primordials.js' -import { isSemverString } from '../strings.js' +import { containsInjectionCharacters, isSemverString } from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -168,9 +168,27 @@ export async function golangExists( /** * Validate Golang package URL. + * Name and namespace must not contain injection characters. * If version starts with "v", it must be followed by a valid semver version. */ export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'golang "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('golang "name" component contains illegal characters') + } + return false + } // Still being lenient here since the standard changes aren't official // Pending spec change: https://github.com/package-url/purl-spec/pull/196 const { version } = purl diff --git a/src/purl-types/hex.ts b/src/purl-types/hex.ts index ee1dbcd..7aff909 100644 --- a/src/purl-types/hex.ts +++ b/src/purl-types/hex.ts @@ -5,8 +5,13 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { lowerName, lowerNamespace } from '../strings.js' +import { + containsInjectionCharacters, + lowerName, + lowerNamespace, +} from '../strings.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -123,3 +128,28 @@ export function normalize(purl: PurlObject): PurlObject { lowerName(purl) return purl } + +/** + * Validate Hex package URL. + * Name and namespace must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'hex "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('hex "name" component contains illegal characters') + } + return false + } + return true +} diff --git a/src/purl-types/julia.ts b/src/purl-types/julia.ts index 068b15c..7715aa8 100644 --- a/src/purl-types/julia.ts +++ b/src/purl-types/julia.ts @@ -6,6 +6,8 @@ * Package names are case-sensitive and typically CamelCase. */ +import { PurlError } from '../error.js' +import { containsInjectionCharacters } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -30,7 +32,18 @@ export function normalize(purl: PurlObject): PurlObject { * Julia packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('julia', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('julia', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('julia "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/maven.ts b/src/purl-types/maven.ts index e4c5067..63b9219 100644 --- a/src/purl-types/maven.ts +++ b/src/purl-types/maven.ts @@ -5,7 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import { validateRequiredByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -133,10 +135,33 @@ export async function mavenExists( /** * Validate Maven package URL. - * Maven packages require a namespace (groupId). + * Maven packages require a namespace (groupId). Name and namespace must not + * contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateRequiredByType('maven', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateRequiredByType('maven', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'maven "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('maven "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/mlflow.ts b/src/purl-types/mlflow.ts index 78a11f8..a216a78 100644 --- a/src/purl-types/mlflow.ts +++ b/src/purl-types/mlflow.ts @@ -3,8 +3,9 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow */ +import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' -import { lowerName } from '../strings.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -33,7 +34,18 @@ export function normalize(purl: PurlObject): PurlObject { * MLflow packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('mlflow', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('mlflow', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('mlflow "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/nuget.ts b/src/purl-types/nuget.ts index 9c75f47..d1d2f16 100644 --- a/src/purl-types/nuget.ts +++ b/src/purl-types/nuget.ts @@ -5,12 +5,14 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, ArrayPrototypePush, StringPrototypeIncludes, StringPrototypeToLowerCase, } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' import { validateEmptyByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -143,10 +145,21 @@ export async function nugetExists( /** * Validate NuGet package URL. - * NuGet packages must not have a namespace. + * NuGet packages must not have a namespace. Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('nuget', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('nuget', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('nuget "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/oci.ts b/src/purl-types/oci.ts index 1092ae5..f2d07b3 100644 --- a/src/purl-types/oci.ts +++ b/src/purl-types/oci.ts @@ -3,7 +3,12 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci */ -import { lowerName, lowerVersion } from '../strings.js' +import { PurlError } from '../error.js' +import { + containsInjectionCharacters, + lowerName, + lowerVersion, +} from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -30,7 +35,18 @@ export function normalize(purl: PurlObject): PurlObject { * OCI packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('oci', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('oci', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('oci "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/opam.ts b/src/purl-types/opam.ts index fdcde03..199ffb6 100644 --- a/src/purl-types/opam.ts +++ b/src/purl-types/opam.ts @@ -5,6 +5,8 @@ * OPAM is the OCaml package manager. Package names are lowercase. */ +import { PurlError } from '../error.js' +import { containsInjectionCharacters } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -21,7 +23,18 @@ interface PurlObject { * OPAM packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('opam', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('opam', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('opam "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/otp.ts b/src/purl-types/otp.ts index db9fb30..fb170fd 100644 --- a/src/purl-types/otp.ts +++ b/src/purl-types/otp.ts @@ -6,7 +6,8 @@ * Package names are typically lowercase. */ -import { lowerName } from '../strings.js' +import { PurlError } from '../error.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -32,7 +33,18 @@ export function normalize(purl: PurlObject): PurlObject { * OTP packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('otp', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('otp', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('otp "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/pypi.ts b/src/purl-types/pypi.ts index 3a58011..e6f0d8c 100644 --- a/src/purl-types/pypi.ts +++ b/src/purl-types/pypi.ts @@ -5,8 +5,10 @@ import { httpJson } from '@socketsecurity/lib/http-request' +import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' import { + containsInjectionCharacters, lowerName, lowerNamespace, lowerVersion, @@ -37,6 +39,20 @@ export function normalize(purl: PurlObject): PurlObject { return purl } +/** + * Validate PyPI package URL. + * Name must not contain injection characters. + */ +export function validate(purl: PurlObject, throws: boolean): boolean { + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('pypi "name" component contains illegal characters') + } + return false + } + return true +} + /** * Check if a PyPI package exists in the registry. * diff --git a/src/purl-types/swid.ts b/src/purl-types/swid.ts index b2d1429..0a72995 100644 --- a/src/purl-types/swid.ts +++ b/src/purl-types/swid.ts @@ -10,6 +10,7 @@ import { StringPrototypeToLowerCase, StringPrototypeTrim, } from '../primordials.js' +import { containsInjectionCharacters } from '../strings.js' const GUID_PATTERN = ObjectFreeze( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, @@ -59,5 +60,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('swid "name" component contains illegal characters') + } + return false + } return true } diff --git a/src/purl-types/swift.ts b/src/purl-types/swift.ts index d10cd56..e583a5b 100644 --- a/src/purl-types/swift.ts +++ b/src/purl-types/swift.ts @@ -3,6 +3,8 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#swift */ +import { PurlError } from '../error.js' +import { containsInjectionCharacters } from '../strings.js' import { validateRequiredByType } from '../validate.js' interface PurlObject { @@ -16,12 +18,36 @@ interface PurlObject { /** * Validate Swift package URL. - * Swift packages require both namespace and version. + * Swift packages require both namespace and version. Name and namespace must + * not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return ( - validateRequiredByType('swift', 'namespace', purl.namespace, { + if ( + !validateRequiredByType('swift', 'namespace', purl.namespace, { throws, - }) && validateRequiredByType('swift', 'version', purl.version, { throws }) - ) + }) + ) { + return false + } + if (!validateRequiredByType('swift', 'version', purl.version, { throws })) { + return false + } + if ( + typeof purl.namespace === 'string' && + containsInjectionCharacters(purl.namespace) + ) { + if (throws) { + throw new PurlError( + 'swift "namespace" component contains illegal characters', + ) + } + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('swift "name" component contains illegal characters') + } + return false + } + return true } diff --git a/src/purl-types/yocto.ts b/src/purl-types/yocto.ts index c06db6c..e42ae49 100644 --- a/src/purl-types/yocto.ts +++ b/src/purl-types/yocto.ts @@ -6,7 +6,8 @@ * Package names are typically lowercase with hyphens. */ -import { lowerName } from '../strings.js' +import { PurlError } from '../error.js' +import { containsInjectionCharacters, lowerName } from '../strings.js' import { validateEmptyByType } from '../validate.js' interface PurlObject { @@ -32,7 +33,18 @@ export function normalize(purl: PurlObject): PurlObject { * Yocto packages must not have a namespace. */ export function validate(purl: PurlObject, throws: boolean): boolean { - return validateEmptyByType('yocto', 'namespace', purl.namespace, { - throws, - }) + if ( + !validateEmptyByType('yocto', 'namespace', purl.namespace, { + throws, + }) + ) { + return false + } + if (containsInjectionCharacters(purl.name)) { + if (throws) { + throw new PurlError('yocto "name" component contains illegal characters') + } + return false + } + return true } diff --git a/test/injection-validation.test.mts b/test/injection-validation.test.mts new file mode 100644 index 0000000..9b77e17 --- /dev/null +++ b/test/injection-validation.test.mts @@ -0,0 +1,370 @@ +/** + * @fileoverview Unit tests for injection character validation in per-type validators. + * Tests that per-type validate functions reject shell/URL injection characters + * in name and namespace components across all package ecosystems. + */ +import { describe, expect, it } from 'vitest' + +import { PurlError } from '../src/error.js' +import { PackageURL } from '../src/package-url.js' + +// Representative injection characters (subset of what containsInjectionCharacters catches) +const INJECTION_CHARS = ['|', '&', ';', '`', '$'] + +// Types with namespace support — test both name and namespace injection +const TYPES_WITH_NAMESPACE: Array<{ + type: string + namespace: string + name: string + version?: string +}> = [ + { + type: 'maven', + namespace: 'org.example', + name: 'artifact', + version: '1.0.0', + }, + { type: 'github', namespace: 'owner', name: 'repo', version: '1.0.0' }, + { type: 'gitlab', namespace: 'owner', name: 'repo', version: '1.0.0' }, + { type: 'bitbucket', namespace: 'owner', name: 'repo', version: '1.0.0' }, + { type: 'docker', namespace: 'library', name: 'nginx', version: 'latest' }, + { + type: 'golang', + namespace: 'github.com/example', + name: 'pkg', + version: 'v1.0.0', + }, + { + type: 'vscode-extension', + namespace: 'publisher', + name: 'ext', + version: '1.0.0', + }, + { + type: 'swift', + namespace: 'github.com/apple', + name: 'swift-nio', + version: '2.0.0', + }, + { type: 'hex', namespace: 'organization', name: 'phoenix', version: '1.0.0' }, + { type: 'cpan', namespace: 'AUTHOR', name: 'module', version: '1.0.0' }, +] + +// Types without namespace — test name injection only +const TYPES_WITHOUT_NAMESPACE: Array<{ + type: string + name: string + version?: string +}> = [ + { type: 'cargo', name: 'serde', version: '1.0.0' }, + { type: 'gem', name: 'rails', version: '7.0.0' }, + { type: 'nuget', name: 'newtonsoft-json', version: '13.0.1' }, + { type: 'conda', name: 'numpy', version: '1.26.3' }, + { type: 'cocoapods', name: 'alamofire', version: '5.0.0' }, + { type: 'pypi', name: 'requests', version: '2.31.0' }, + { type: 'bazel', name: 'rules-go', version: '0.41.0' }, + { type: 'cran', name: 'ggplot2', version: '3.4.0' }, + { type: 'oci', name: 'myimage', version: '1.0.0' }, + { type: 'opam', name: 'ocaml', version: '5.1.0' }, + { type: 'otp', name: 'cowboy', version: '2.10.0' }, + { type: 'julia', name: 'dataframes', version: '1.5.0' }, + { type: 'mlflow', name: 'mymodel', version: '1.0.0' }, + { type: 'yocto', name: 'zlib', version: '1.2.11' }, +] + +describe('Per-type injection character validation', () => { + describe('Name injection rejection across types', () => { + const allTypes = [ + ...TYPES_WITH_NAMESPACE.map(t => ({ + ...t, + ns: t.namespace, + })), + ...TYPES_WITHOUT_NAMESPACE.map(t => ({ + ...t, + ns: undefined as string | undefined, + })), + ] + + for (const { type, name, ns, version } of allTypes) { + it(`should reject injection characters in ${type} name`, () => { + for (const char of INJECTION_CHARS) { + expect( + () => + new PackageURL( + type, + ns, + `${name}${char}x`, + version ?? '1.0.0', + undefined, + undefined, + ), + `${type}: name with ${JSON.stringify(char)}`, + ).toThrow(PurlError) + } + }) + } + }) + + describe('Namespace injection rejection across types', () => { + for (const { type, namespace, name, version } of TYPES_WITH_NAMESPACE) { + it(`should reject injection characters in ${type} namespace`, () => { + for (const char of INJECTION_CHARS) { + expect( + () => + new PackageURL( + type, + `${namespace}${char}x`, + name, + version ?? '1.0.0', + undefined, + undefined, + ), + `${type}: namespace with ${JSON.stringify(char)}`, + ).toThrow(PurlError) + } + }) + } + }) + + describe('Valid names still accepted', () => { + it('should accept valid package names across types', () => { + // npm + expect( + new PackageURL( + 'npm', + undefined, + 'lodash', + '4.17.21', + undefined, + undefined, + ).name, + ).toBe('lodash') + // cargo + expect( + new PackageURL( + 'cargo', + undefined, + 'serde', + '1.0.0', + undefined, + undefined, + ).name, + ).toBe('serde') + // maven with namespace + expect( + new PackageURL( + 'maven', + 'org.apache', + 'commons-lang3', + '3.12.0', + undefined, + undefined, + ).name, + ).toBe('commons-lang3') + // github with namespace + expect( + new PackageURL( + 'github', + 'socketdev', + 'socket-sdk-js', + '1.0.0', + undefined, + undefined, + ).name, + ).toBe('socket-sdk-js') + // docker + expect( + new PackageURL( + 'docker', + 'library', + 'nginx', + 'latest', + undefined, + undefined, + ).name, + ).toBe('nginx') + // pypi (normalized) + expect( + new PackageURL( + 'pypi', + undefined, + 'requests', + '2.31.0', + undefined, + undefined, + ).name, + ).toBe('requests') + }) + }) + + describe('Legitimate version formats are not blocked', () => { + it('should allow Maven versions with spaces (URL-encoded)', () => { + const purl = PackageURL.fromString( + 'pkg:maven/mygroup/myartifact@1.0.0%20Final', + ) + expect(purl.version).toBe('1.0.0 Final') + }) + + it('should allow semver with prerelease and build metadata', () => { + expect( + new PackageURL( + 'npm', + undefined, + 'lodash', + '4.17.21-beta.1', + undefined, + undefined, + ).version, + ).toBe('4.17.21-beta.1') + expect( + new PackageURL( + 'npm', + undefined, + 'lodash', + '4.17.21+build.123', + undefined, + undefined, + ).version, + ).toBe('4.17.21+build.123') + }) + + it('should allow golang v-prefixed versions', () => { + expect( + new PackageURL( + 'golang', + 'github.com/example', + 'pkg', + 'v1.2.3', + undefined, + undefined, + ).version, + ).toBe('v1.2.3') + }) + }) + + describe('PackageURL.fromString - encoded injection characters', () => { + it('should reject injection characters that survive URL decoding in name', () => { + // %7C decodes to | + expect(() => PackageURL.fromString('pkg:cargo/serde%7Cx@1.0.0')).toThrow( + PurlError, + ) + // %26 decodes to & + expect(() => PackageURL.fromString('pkg:gem/rails%26x@7.0.0')).toThrow( + PurlError, + ) + }) + + it('should reject injection characters that survive URL decoding in namespace', () => { + // %7C decodes to | + expect(() => + PackageURL.fromString('pkg:maven/org%7Cevil/artifact@1.0.0'), + ).toThrow(PurlError) + // %3B decodes to ; + expect(() => + PackageURL.fromString('pkg:github/owner%3Bx/repo@1.0.0'), + ).toThrow(PurlError) + }) + }) + + describe('Whitespace injection variants', () => { + it('should reject space in name', () => { + expect( + () => + new PackageURL( + 'cargo', + undefined, + 'my package', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject tab in name', () => { + expect( + () => + new PackageURL( + 'gem', + undefined, + 'my\tpackage', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject newline in name', () => { + expect( + () => + new PackageURL( + 'nuget', + undefined, + 'my\npackage', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject space in namespace', () => { + expect( + () => + new PackageURL( + 'maven', + 'org evil', + 'artifact', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + }) + + describe('Types requiring special qualifiers', () => { + it('should reject injection characters in conan name', () => { + expect( + () => + new PackageURL( + 'conan', + undefined, + 'zlib|evil', + '1.2.13', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject injection characters in conan namespace', () => { + expect( + () => + new PackageURL( + 'conan', + 'user|evil', + 'zlib', + '1.2.13', + { channel: 'stable' }, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should reject injection characters in swid name', () => { + expect( + () => + new PackageURL( + 'swid', + undefined, + 'app|evil', + '1.0.0', + { tag_id: 'test-tag' }, + undefined, + ), + ).toThrow(PurlError) + }) + }) +}) diff --git a/test/purl-edge-cases.test.mts b/test/purl-edge-cases.test.mts index c440117..8185de3 100644 --- a/test/purl-edge-cases.test.mts +++ b/test/purl-edge-cases.test.mts @@ -1966,7 +1966,7 @@ describe('Edge cases and additional coverage', () => { // Test name with whitespace expect( () => new PackageURL('cocoapods', null, 'Pod Name', null, null, null), - ).toThrow('cocoapods "name" component cannot contain whitespace') + ).toThrow('cocoapods "name" component contains illegal characters') // Test name with plus character expect( From 3cf4b465fbf02d6a1c8296054a5b6df937f7ef12 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 16:30:52 -0400 Subject: [PATCH 4/6] refactor(security): centralize injection validation, harden scanner, add PurlInjectionError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture improvements: - Default validator in purl-type.ts now runs injection checks for all registered types — security is opt-out, not opt-in. Unregistered types (used by purl spec tests) bypass the default, preserving spec compliance. - New shared validateNoInjectionByType() helper in validate.ts eliminates 6-line injection check boilerplate from 26 per-type validators. - Per-type validators now only contain ecosystem-specific rules. Hardened scanner (containsInjectionCharacters): - Added single quote (') and double quote (") detection — prevents quote-breaking attacks in shell, SQL, and URL contexts - Added full C0 control character range (0x00-0x1f) — catches ESC (terminal escape sequences), BEL, vertical tab, form feed, and all other control chars used for log/terminal injection - Added DEL (0x7f) detection — control character used in terminal attacks - Extracted isInjectionCharCode() for the core detection logic - Added findInjectionCharCode() returning the offending char code - Added formatInjectionChar() for human-readable error labels New PurlInjectionError class: - Subclass of PurlError — catchable as either type for flexible handling - Exposes charCode, component, and purlType properties for programmatic inspection by security tooling - Error messages include the specific character found, e.g.: 'maven "namespace" component contains injection character ";" (0x3b)' - Control characters formatted as hex: '0x1b' (not the raw ESC byte) --- src/error.ts | 33 ++++++- src/index.ts | 8 +- src/purl-type.ts | 36 ++++++- src/purl-types/bazel.ts | 8 +- src/purl-types/bitbucket.ts | 23 +---- src/purl-types/cargo.ts | 9 +- src/purl-types/cocoapods.ts | 9 +- src/purl-types/conan.ts | 15 +-- src/purl-types/conda.ts | 10 +- src/purl-types/cpan.ts | 14 +-- src/purl-types/cran.ts | 12 +-- src/purl-types/docker.ts | 17 +--- src/purl-types/gem.ts | 9 +- src/purl-types/github.ts | 21 +--- src/purl-types/gitlab.ts | 21 +--- src/purl-types/golang.ts | 16 +-- src/purl-types/hex.ts | 23 +---- src/purl-types/julia.ts | 9 +- src/purl-types/maven.ts | 20 ++-- src/purl-types/mlflow.ts | 10 +- src/purl-types/nuget.ts | 9 +- src/purl-types/oci.ts | 14 +-- src/purl-types/opam.ts | 9 +- src/purl-types/otp.ts | 10 +- src/purl-types/pypi.ts | 8 +- src/purl-types/swid.ts | 7 +- src/purl-types/swift.ts | 20 ++-- src/purl-types/vscode-extension.ts | 42 ++++---- src/purl-types/yocto.ts | 10 +- src/strings.ts | 151 +++++++++++++++++++--------- src/validate.ts | 40 +++++++- test/injection-validation.test.mts | 152 ++++++++++++++++++++++++++++- test/purl-edge-cases.test.mts | 2 +- test/strings.test.mts | 16 +++ 34 files changed, 488 insertions(+), 325 deletions(-) diff --git a/src/error.ts b/src/error.ts index 8f9f582..5ba12be 100644 --- a/src/error.ts +++ b/src/error.ts @@ -46,4 +46,35 @@ class PurlError extends Error { } } -export { formatPurlErrorMessage, PurlError } +/** + * Specialized error for injection character detection. + * Developers can catch this specifically to distinguish injection rejections + * from other PURL validation errors and handle them at an elevated level + * (e.g., logging, alerting, blocking). + * + * Properties: + * - `component` — which PURL component was rejected ("name", "namespace") + * - `charCode` — the character code of the injection character found + * - `purlType` — the package type (e.g., "npm", "maven") + */ +class PurlInjectionError extends PurlError { + readonly charCode: number + readonly component: string + readonly purlType: string + + constructor( + purlType: string, + component: string, + charCode: number, + charLabel: string, + ) { + super( + `${purlType} "${component}" component contains injection character ${charLabel}`, + ) + this.charCode = charCode + this.component = component + this.purlType = purlType + } +} + +export { formatPurlErrorMessage, PurlError, PurlInjectionError } diff --git a/src/index.ts b/src/index.ts index 7674b64..3d1b408 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,7 @@ export { UrlConverter, } from './package-url.js' export { PurlBuilder } from './package-url-builder.js' -export { PurlError } from './error.js' +export { PurlError, PurlInjectionError } from './error.js' // ============================================================================ // Modular Utilities // ============================================================================ @@ -96,7 +96,11 @@ export { parseNpmSpecifier } from './purl-types/npm.js' // separate entry point: import { npmExists } from '@socketregistry/packageurl-js/exists' // This keeps the core bundle lean (~200 KB vs 3.3 MB with HTTP deps). export type { ExistsOptions, ExistsResult } from './purl-types/npm.js' -export { containsInjectionCharacters } from './strings.js' +export { + containsInjectionCharacters, + findInjectionCharCode, + formatInjectionChar, +} from './strings.js' export { stringify, stringifySpec } from './stringify.js' export { Vers } from './vers.js' export type { VersComparator, VersConstraint, VersWildcard } from './vers.js' diff --git a/src/purl-type.ts b/src/purl-type.ts index b7a02f3..351c7dd 100644 --- a/src/purl-type.ts +++ b/src/purl-type.ts @@ -6,7 +6,9 @@ * module in the purl-types/ directory with specific rules for namespace, name, version * normalization and validation. */ +import { PurlInjectionError } from './error.js' import { createHelpersNamespaceObject } from './helpers.js' +import { findInjectionCharCode, formatInjectionChar } from './strings.js' import { normalize as alpmNormalize } from './purl-types/alpm.js' import { normalize as apkNormalize } from './purl-types/apk.js' import { @@ -112,8 +114,40 @@ const PurlTypNormalizer = (purl: PurlObject): PurlObject => purl /** * Default validator for PURL types without specific validation rules. + * Rejects injection characters in name and namespace components. + * This ensures all types (including newly added ones) get injection + * protection by default — security is opt-out, not opt-in. */ -const PurlTypeValidator = (_purl: PurlObject, _throws: boolean): boolean => true +function PurlTypeValidator(purl: PurlObject, throws: boolean): boolean { + const type = purl.type ?? 'unknown' + if (typeof purl.namespace === 'string') { + const nsCode = findInjectionCharCode(purl.namespace) + if (nsCode !== -1) { + if (throws) { + throw new PurlInjectionError( + type, + 'namespace', + nsCode, + formatInjectionChar(nsCode), + ) + } + return false + } + } + const nameCode = findInjectionCharCode(purl.name) + if (nameCode !== -1) { + if (throws) { + throw new PurlInjectionError( + type, + 'name', + nameCode, + formatInjectionChar(nameCode), + ) + } + return false + } + return true +} // PURL types: // https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst diff --git a/src/purl-types/bazel.ts b/src/purl-types/bazel.ts index 0fa8e71..4bab260 100644 --- a/src/purl-types/bazel.ts +++ b/src/purl-types/bazel.ts @@ -7,7 +7,8 @@ */ import { PurlError } from '../error.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' +import { lowerName } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -39,10 +40,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('bazel "name" component contains illegal characters') - } + if (!validateNoInjectionByType('bazel', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/bitbucket.ts b/src/purl-types/bitbucket.ts index c7c95fd..1a1df29 100644 --- a/src/purl-types/bitbucket.ts +++ b/src/purl-types/bitbucket.ts @@ -3,12 +3,8 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket */ -import { PurlError } from '../error.js' -import { - containsInjectionCharacters, - lowerName, - lowerNamespace, -} from '../strings.js' +import { lowerName, lowerNamespace } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -35,22 +31,11 @@ export function normalize(purl: PurlObject): PurlObject { */ export function validate(purl: PurlObject, throws: boolean): boolean { if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('bitbucket', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'bitbucket "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError( - 'bitbucket "name" component contains illegal characters', - ) - } + if (!validateNoInjectionByType('bitbucket', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/cargo.ts b/src/purl-types/cargo.ts index d74ada5..c8ebbfb 100644 --- a/src/purl-types/cargo.ts +++ b/src/purl-types/cargo.ts @@ -5,10 +5,8 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -149,10 +147,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('cargo "name" component contains illegal characters') - } + if (!validateNoInjectionByType('cargo', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/cocoapods.ts b/src/purl-types/cocoapods.ts index 1bf8de1..b96f5af 100644 --- a/src/purl-types/cocoapods.ts +++ b/src/purl-types/cocoapods.ts @@ -11,7 +11,7 @@ import { StringPrototypeIncludes, ArrayPrototypeSome, } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -131,12 +131,7 @@ export async function cocoapodsExists( export function validate(purl: PurlObject, throws: boolean): boolean { const { name } = purl // Name must not contain injection characters - if (containsInjectionCharacters(name)) { - if (throws) { - throw new PurlError( - 'cocoapods "name" component contains illegal characters', - ) - } + if (!validateNoInjectionByType('cocoapods', 'name', name, throws)) { return false } // Name cannot contain a plus (+) character diff --git a/src/purl-types/conan.ts b/src/purl-types/conan.ts index c55e412..5d840db 100644 --- a/src/purl-types/conan.ts +++ b/src/purl-types/conan.ts @@ -5,7 +5,7 @@ import { PurlError } from '../error.js' import { isNullishOrEmptyString } from '../lang.js' -import { containsInjectionCharacters } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -40,20 +40,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('conan', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'conan "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('conan "name" component contains illegal characters') - } + if (!validateNoInjectionByType('conan', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/conda.ts b/src/purl-types/conda.ts index 995bc3d..151bf95 100644 --- a/src/purl-types/conda.ts +++ b/src/purl-types/conda.ts @@ -5,13 +5,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, StringPrototypeIncludes, } from '../primordials.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { lowerName } from '../strings.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -45,10 +44,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('conda "name" component contains illegal characters') - } + if (!validateNoInjectionByType('conda', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/cpan.ts b/src/purl-types/cpan.ts index 3991f0e..288058c 100644 --- a/src/purl-types/cpan.ts +++ b/src/purl-types/cpan.ts @@ -10,7 +10,7 @@ import { StringPrototypeIncludes, StringPrototypeToUpperCase, } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -130,18 +130,10 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } - if (typeof namespace === 'string' && containsInjectionCharacters(namespace)) { - if (throws) { - throw new PurlError( - 'cpan "namespace" component contains illegal characters', - ) - } + if (!validateNoInjectionByType('cpan', 'namespace', namespace, throws)) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('cpan "name" component contains illegal characters') - } + if (!validateNoInjectionByType('cpan', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/cran.ts b/src/purl-types/cran.ts index f74f199..8c2c5b8 100644 --- a/src/purl-types/cran.ts +++ b/src/purl-types/cran.ts @@ -5,13 +5,14 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, StringPrototypeIncludes, } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -131,10 +132,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('cran "name" component contains illegal characters') - } + if (!validateNoInjectionByType('cran', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/docker.ts b/src/purl-types/docker.ts index 73130e3..683de28 100644 --- a/src/purl-types/docker.ts +++ b/src/purl-types/docker.ts @@ -5,9 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' +import { lowerName } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -35,20 +35,11 @@ export function normalize(purl: PurlObject): PurlObject { */ export function validate(purl: PurlObject, throws: boolean): boolean { if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('docker', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'docker "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('docker "name" component contains illegal characters') - } + if (!validateNoInjectionByType('docker', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/gem.ts b/src/purl-types/gem.ts index f04815c..5356e0b 100644 --- a/src/purl-types/gem.ts +++ b/src/purl-types/gem.ts @@ -5,10 +5,8 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -143,10 +141,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('gem "name" component contains illegal characters') - } + if (!validateNoInjectionByType('gem', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/github.ts b/src/purl-types/github.ts index 2fee641..f2b3c4a 100644 --- a/src/purl-types/github.ts +++ b/src/purl-types/github.ts @@ -3,12 +3,8 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#github */ -import { PurlError } from '../error.js' -import { - containsInjectionCharacters, - lowerName, - lowerNamespace, -} from '../strings.js' +import { lowerName, lowerNamespace } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -35,20 +31,11 @@ export function normalize(purl: PurlObject): PurlObject { */ export function validate(purl: PurlObject, throws: boolean): boolean { if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('github', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'github "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('github "name" component contains illegal characters') - } + if (!validateNoInjectionByType('github', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/gitlab.ts b/src/purl-types/gitlab.ts index 44a25b8..5c01a89 100644 --- a/src/purl-types/gitlab.ts +++ b/src/purl-types/gitlab.ts @@ -3,12 +3,8 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#other-candidate-types-to-define */ -import { PurlError } from '../error.js' -import { - containsInjectionCharacters, - lowerName, - lowerNamespace, -} from '../strings.js' +import { lowerName, lowerNamespace } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -35,20 +31,11 @@ export function normalize(purl: PurlObject): PurlObject { */ export function validate(purl: PurlObject, throws: boolean): boolean { if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('gitlab', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'gitlab "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('gitlab "name" component contains illegal characters') - } + if (!validateNoInjectionByType('gitlab', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/golang.ts b/src/purl-types/golang.ts index 1c988a8..c571a3e 100644 --- a/src/purl-types/golang.ts +++ b/src/purl-types/golang.ts @@ -40,7 +40,8 @@ import { StringPrototypeSplit, StringPrototypeToLowerCase, } from '../primordials.js' -import { containsInjectionCharacters, isSemverString } from '../strings.js' +import { isSemverString } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -173,20 +174,11 @@ export async function golangExists( */ export function validate(purl: PurlObject, throws: boolean): boolean { if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('golang', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'golang "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('golang "name" component contains illegal characters') - } + if (!validateNoInjectionByType('golang', 'name', purl.name, throws)) { return false } // Still being lenient here since the standard changes aren't official diff --git a/src/purl-types/hex.ts b/src/purl-types/hex.ts index 7aff909..0aabec0 100644 --- a/src/purl-types/hex.ts +++ b/src/purl-types/hex.ts @@ -5,13 +5,9 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { - containsInjectionCharacters, - lowerName, - lowerNamespace, -} from '../strings.js' +import { lowerName, lowerNamespace } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -134,21 +130,10 @@ export function normalize(purl: PurlObject): PurlObject { * Name and namespace must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) - ) { - if (throws) { - throw new PurlError( - 'hex "namespace" component contains illegal characters', - ) - } + if (!validateNoInjectionByType('hex', 'namespace', purl.namespace, throws)) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('hex "name" component contains illegal characters') - } + if (!validateNoInjectionByType('hex', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/julia.ts b/src/purl-types/julia.ts index 7715aa8..9c7c0f4 100644 --- a/src/purl-types/julia.ts +++ b/src/purl-types/julia.ts @@ -6,9 +6,7 @@ * Package names are case-sensitive and typically CamelCase. */ -import { PurlError } from '../error.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -39,10 +37,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('julia "name" component contains illegal characters') - } + if (!validateNoInjectionByType('julia', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/maven.ts b/src/purl-types/maven.ts index 63b9219..9dc9712 100644 --- a/src/purl-types/maven.ts +++ b/src/purl-types/maven.ts @@ -5,10 +5,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -147,20 +148,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('maven', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'maven "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('maven "name" component contains illegal characters') - } + if (!validateNoInjectionByType('maven', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/mlflow.ts b/src/purl-types/mlflow.ts index a216a78..0d828ca 100644 --- a/src/purl-types/mlflow.ts +++ b/src/purl-types/mlflow.ts @@ -3,10 +3,9 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#mlflow */ -import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { lowerName } from '../strings.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -41,10 +40,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('mlflow "name" component contains illegal characters') - } + if (!validateNoInjectionByType('mlflow', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/nuget.ts b/src/purl-types/nuget.ts index d1d2f16..4a862e7 100644 --- a/src/purl-types/nuget.ts +++ b/src/purl-types/nuget.ts @@ -5,15 +5,13 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { ArrayPrototypeIncludes, ArrayPrototypePush, StringPrototypeIncludes, StringPrototypeToLowerCase, } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -155,10 +153,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('nuget "name" component contains illegal characters') - } + if (!validateNoInjectionByType('nuget', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/oci.ts b/src/purl-types/oci.ts index f2d07b3..d6c92a4 100644 --- a/src/purl-types/oci.ts +++ b/src/purl-types/oci.ts @@ -3,13 +3,8 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#oci */ -import { PurlError } from '../error.js' -import { - containsInjectionCharacters, - lowerName, - lowerVersion, -} from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { lowerName, lowerVersion } from '../strings.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -42,10 +37,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('oci "name" component contains illegal characters') - } + if (!validateNoInjectionByType('oci', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/opam.ts b/src/purl-types/opam.ts index 199ffb6..2dad8eb 100644 --- a/src/purl-types/opam.ts +++ b/src/purl-types/opam.ts @@ -5,9 +5,7 @@ * OPAM is the OCaml package manager. Package names are lowercase. */ -import { PurlError } from '../error.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -30,10 +28,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('opam "name" component contains illegal characters') - } + if (!validateNoInjectionByType('opam', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/otp.ts b/src/purl-types/otp.ts index fb170fd..75dc1d9 100644 --- a/src/purl-types/otp.ts +++ b/src/purl-types/otp.ts @@ -6,9 +6,8 @@ * Package names are typically lowercase. */ -import { PurlError } from '../error.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { lowerName } from '../strings.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -40,10 +39,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('otp "name" component contains illegal characters') - } + if (!validateNoInjectionByType('otp', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/pypi.ts b/src/purl-types/pypi.ts index e6f0d8c..8b2afa9 100644 --- a/src/purl-types/pypi.ts +++ b/src/purl-types/pypi.ts @@ -5,15 +5,14 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { PurlError } from '../error.js' import { StringPrototypeIncludes } from '../primordials.js' import { - containsInjectionCharacters, lowerName, lowerNamespace, lowerVersion, replaceUnderscoresWithDashes, } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -44,10 +43,7 @@ export function normalize(purl: PurlObject): PurlObject { * Name must not contain injection characters. */ export function validate(purl: PurlObject, throws: boolean): boolean { - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('pypi "name" component contains illegal characters') - } + if (!validateNoInjectionByType('pypi', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/swid.ts b/src/purl-types/swid.ts index 0a72995..289ae2e 100644 --- a/src/purl-types/swid.ts +++ b/src/purl-types/swid.ts @@ -10,7 +10,7 @@ import { StringPrototypeToLowerCase, StringPrototypeTrim, } from '../primordials.js' -import { containsInjectionCharacters } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' const GUID_PATTERN = ObjectFreeze( /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, @@ -60,10 +60,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('swid "name" component contains illegal characters') - } + if (!validateNoInjectionByType('swid', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/swift.ts b/src/purl-types/swift.ts index e583a5b..c2de5fe 100644 --- a/src/purl-types/swift.ts +++ b/src/purl-types/swift.ts @@ -3,9 +3,10 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#swift */ -import { PurlError } from '../error.js' -import { containsInjectionCharacters } from '../strings.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' interface PurlObject { name: string @@ -33,20 +34,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } if ( - typeof purl.namespace === 'string' && - containsInjectionCharacters(purl.namespace) + !validateNoInjectionByType('swift', 'namespace', purl.namespace, throws) ) { - if (throws) { - throw new PurlError( - 'swift "namespace" component contains illegal characters', - ) - } return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('swift "name" component contains illegal characters') - } + if (!validateNoInjectionByType('swift', 'name', purl.name, throws)) { return false } return true diff --git a/src/purl-types/vscode-extension.ts b/src/purl-types/vscode-extension.ts index 531d7e0..a3d0501 100644 --- a/src/purl-types/vscode-extension.ts +++ b/src/purl-types/vscode-extension.ts @@ -11,13 +11,15 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { - containsInjectionCharacters, isSemverString, lowerName, lowerNamespace, lowerVersion, } from '../strings.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -58,21 +60,18 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // Namespace must not contain injection characters - if (typeof namespace === 'string' && containsInjectionCharacters(namespace)) { - if (throws) { - throw new PurlError( - 'vscode-extension "namespace" component contains illegal characters', - ) - } + if ( + !validateNoInjectionByType( + 'vscode-extension', + 'namespace', + namespace, + throws, + ) + ) { return false } // Name must not contain injection characters - if (containsInjectionCharacters(name)) { - if (throws) { - throw new PurlError( - 'vscode-extension "name" component contains illegal characters', - ) - } + if (!validateNoInjectionByType('vscode-extension', 'name', name, throws)) { return false } // Version must be valid semver when present @@ -89,13 +88,14 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } // Platform qualifier must not contain injection characters - const platform = qualifiers?.['platform'] - if (typeof platform === 'string' && containsInjectionCharacters(platform)) { - if (throws) { - throw new PurlError( - 'vscode-extension qualifier "platform" contains illegal characters', - ) - } + if ( + !validateNoInjectionByType( + 'vscode-extension', + 'platform', + qualifiers?.['platform'], + throws, + ) + ) { return false } return true diff --git a/src/purl-types/yocto.ts b/src/purl-types/yocto.ts index e42ae49..6f261b6 100644 --- a/src/purl-types/yocto.ts +++ b/src/purl-types/yocto.ts @@ -6,9 +6,8 @@ * Package names are typically lowercase with hyphens. */ -import { PurlError } from '../error.js' -import { containsInjectionCharacters, lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { lowerName } from '../strings.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -40,10 +39,7 @@ export function validate(purl: PurlObject, throws: boolean): boolean { ) { return false } - if (containsInjectionCharacters(purl.name)) { - if (throws) { - throw new PurlError('yocto "name" component contains illegal characters') - } + if (!validateNoInjectionByType('yocto', 'name', purl.name, throws)) { return false } return true diff --git a/src/strings.ts b/src/strings.ts index b3abf23..a82c9f6 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -181,55 +181,116 @@ function replaceUnderscoresWithDashes(str: string): string { } /** - * Check if string contains characters commonly used in shell/URL injection attacks. - * Detects shell metacharacters (|, &, ;, `, $, <, >, {, }, #, \, newlines) - * and whitespace that could be used to break out of command or URL contexts. - * Uses charCode scanning for performance in hot paths. + * Test whether a character code is an injection-dangerous character. + * + * Detects four classes of dangerous characters: + * + * 1. **Shell metacharacters** — command execution, piping, redirection, expansion: + * |, &, ;, `, $, <, >, (, ), {, }, \ + * + * 2. **Quote characters** — break out of quoted contexts in shell, SQL, URLs: + * ', " + * + * 3. **URL/path delimiters** — fragment injection, comment injection: + * # + * + * 4. **Whitespace & control characters** — argument splitting, log injection, + * terminal escape sequences, null-byte truncation: + * 0x00-0x1f (all C0 controls including NUL, tab, newline, CR, ESC, etc.) + * space (0x20), DEL (0x7f) */ -function containsInjectionCharacters(str: string): boolean { +function isInjectionCharCode(code: number): boolean { + // C0 control characters (0x00-0x1f) — includes NUL, tab, newline, CR, + // ESC (0x1b, terminal escape sequences), and all other control chars. + // Also catches vertical tab (0x0b), form feed (0x0c), and bell (0x07) + // which can be used for log injection and terminal manipulation. + if (code <= 0x1f) { + return true + } + // biome-ignore format: newlines + if ( + // space — argument splitting in shell contexts + code === 0x20 || + // " — breaks double-quoted shell/SQL/URL contexts + code === 0x22 || + // # — URL fragment injection, shell comments + code === 0x23 || + // $ — shell variable expansion, command substitution $() + code === 0x24 || + // & — shell background execution, URL parameter delimiter + code === 0x26 || + // ' — breaks single-quoted shell/SQL contexts + code === 0x27 || + // ( — shell subshell, command grouping + code === 0x28 || + // ) — shell subshell, command grouping + code === 0x29 || + // ; — shell command separator + code === 0x3b || + // < — shell input redirection, XML/HTML injection + code === 0x3c || + // > — shell output redirection, XML/HTML injection + code === 0x3e || + // \ — shell escape character, path traversal on Windows + code === 0x5c || + // ` — shell command substitution (legacy backtick form) + code === 0x60 || + // { — shell brace expansion + code === 0x7b || + // | — shell pipe + code === 0x7c || + // } — shell brace expansion + code === 0x7d || + // DEL (0x7f) — control character, terminal manipulation + code === 0x7f + ) { + return true + } + return false +} + +/** + * Find the first injection character in a string. + * Returns the character code of the first dangerous character found, or -1. + * + * Uses charCode scanning for performance in hot paths. The check is a + * single pass with no allocation, no regex, and no prototype method calls + * beyond the captured StringPrototypeCharCodeAt primordial. + * + * Null bytes (0x00) are also caught by validateStrings() in validate.ts, + * but we include them here for defense-in-depth so callers who skip the + * base validators still get protection. + */ +function findInjectionCharCode(str: string): number { for (let i = 0, { length } = str; i < length; i += 1) { const code = StringPrototypeCharCodeAt(str, i) - // biome-ignore format: newlines - if ( - // | - code === 0x7c || - // & - code === 0x26 || - // ; - code === 0x3b || - // ` - code === 0x60 || - // $ - code === 0x24 || - // < - code === 0x3c || - // > - code === 0x3e || - // ( - code === 0x28 || - // ) - code === 0x29 || - // { - code === 0x7b || - // } - code === 0x7d || - // # - code === 0x23 || - // \ - code === 0x5c || - // space - code === 0x20 || - // tab - code === 0x09 || - // newline - code === 0x0a || - // carriage return - code === 0x0d - ) { - return true + if (isInjectionCharCode(code)) { + return code } } - return false + return -1 +} + +/** + * Check if string contains characters commonly used in injection attacks. + * Returns true if any dangerous character is found. + * + * For detailed information about which character was found, use + * {@link findInjectionCharCode} instead. + */ +function containsInjectionCharacters(str: string): boolean { + return findInjectionCharCode(str) !== -1 +} + +/** + * Format an injection character code as a human-readable label for error messages. + * Returns a string like `"|" (0x7c)` for printable chars or `0x1b` for control chars. + */ +function formatInjectionChar(code: number): string { + if (code >= 0x20 && code <= 0x7e) { + return `"${String.fromCharCode(code)}" (0x${code.toString(16)})` + } + return `0x${code.toString(16).padStart(2, '0')}` } /** @@ -245,6 +306,8 @@ function trimLeadingSlashes(str: string): string { export { containsInjectionCharacters, + findInjectionCharCode, + formatInjectionChar, isBlank, isNonEmptyString, isSemverString, diff --git a/src/validate.ts b/src/validate.ts index 58b7d5f..12681fa 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -2,14 +2,18 @@ * @fileoverview Validation functions for PURL components. * Ensures compliance with Package URL specification requirements and constraints. */ -import { PurlError } from './error.js' +import { PurlError, PurlInjectionError } from './error.js' import { isNullishOrEmptyString } from './lang.js' import { ReflectApply, StringPrototypeCharCodeAt, StringPrototypeIncludes, } from './primordials.js' -import { isNonEmptyString } from './strings.js' +import { + findInjectionCharCode, + formatInjectionChar, + isNonEmptyString, +} from './strings.js' import type { QualifiersObject } from './purl-component.js' @@ -34,6 +38,37 @@ function validateEmptyByType( return true } +/** + * Validate that a component does not contain injection characters. + * Shared helper to eliminate boilerplate across per-type validators. + * @throws {PurlInjectionError} When validation fails and throws is true. + * The error includes the specific character code, component name, and + * package type so callers can log, alert, or handle injection attempts + * at an elevated level. + */ +function validateNoInjectionByType( + type: string, + component: string, + value: string | undefined, + throws: boolean, +): boolean { + if (typeof value === 'string') { + const code = findInjectionCharCode(value) + if (code !== -1) { + if (throws) { + throw new PurlInjectionError( + type, + component, + code, + formatInjectionChar(code), + ) + } + return false + } + } + return true +} + /** * Validate package name component. * @throws {PurlError} When validation fails and options.throws is true. @@ -390,6 +425,7 @@ export { validateEmptyByType, validateName, validateNamespace, + validateNoInjectionByType, validateQualifiers, validateQualifierKey, validateRequired, diff --git a/test/injection-validation.test.mts b/test/injection-validation.test.mts index 9b77e17..362ec0a 100644 --- a/test/injection-validation.test.mts +++ b/test/injection-validation.test.mts @@ -5,8 +5,25 @@ */ import { describe, expect, it } from 'vitest' -import { PurlError } from '../src/error.js' +import { PurlError, PurlInjectionError } from '../src/error.js' import { PackageURL } from '../src/package-url.js' +import { + containsInjectionCharacters, + findInjectionCharCode, + formatInjectionChar, +} from '../src/strings.js' + +/** Helper to catch and return a PurlInjectionError for property inspection. */ +function getInjectionError(fn: () => unknown): PurlInjectionError { + let caught: unknown + try { + fn() + } catch (e) { + caught = e + } + expect(caught).toBeInstanceOf(PurlInjectionError) + return caught as PurlInjectionError +} // Representative injection characters (subset of what containsInjectionCharacters catches) const INJECTION_CHARS = ['|', '&', ';', '`', '$'] @@ -367,4 +384,137 @@ describe('Per-type injection character validation', () => { ).toThrow(PurlError) }) }) + + describe('PurlInjectionError', () => { + it('should be an instance of both PurlInjectionError and PurlError', () => { + expect( + () => + new PackageURL( + 'cargo', + undefined, + 'pkg|evil', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlInjectionError) + }) + + it('should be catchable as PurlError (superclass)', () => { + expect( + () => + new PackageURL( + 'cargo', + undefined, + 'pkg|evil', + '1.0.0', + undefined, + undefined, + ), + ).toThrow(PurlError) + }) + + it('should expose charCode, component, and purlType properties', () => { + const err = getInjectionError( + () => + new PackageURL( + 'maven', + 'org;evil', + 'artifact', + '1.0.0', + undefined, + undefined, + ), + ) + expect(err.purlType).toBe('maven') + expect(err.component).toBe('namespace') + expect(err.charCode).toBe(0x3b) // semicolon + }) + + it('should include the specific character in the error message', () => { + const err = getInjectionError( + () => + new PackageURL( + 'cargo', + undefined, + 'pkg$name', + '1.0.0', + undefined, + undefined, + ), + ) + expect(err.message).toContain('"$" (0x24)') + }) + + it('should format control characters as hex codes', () => { + const err = getInjectionError( + () => + new PackageURL( + 'gem', + undefined, + 'pkg\x1bname', + '1.0.0', + undefined, + undefined, + ), + ) + expect(err.charCode).toBe(0x1b) // ESC + expect(err.message).toContain('0x1b') + }) + }) + + describe('Hardened scanner - newly detected characters', () => { + it('should detect single and double quotes', () => { + expect(containsInjectionCharacters("pkg'name")).toBe(true) + expect(containsInjectionCharacters('pkg"name')).toBe(true) + }) + + it('should detect control characters (C0 range)', () => { + // ESC (terminal escape sequences) + expect(containsInjectionCharacters('pkg\x1bname')).toBe(true) + // NUL + expect(containsInjectionCharacters('pkg\x00name')).toBe(true) + // BEL (terminal bell) + expect(containsInjectionCharacters('pkg\x07name')).toBe(true) + // Vertical tab + expect(containsInjectionCharacters('pkg\x0bname')).toBe(true) + // Form feed + expect(containsInjectionCharacters('pkg\x0cname')).toBe(true) + }) + + it('should detect DEL character', () => { + expect(containsInjectionCharacters('pkg\x7fname')).toBe(true) + }) + }) + + describe('findInjectionCharCode', () => { + it('should return -1 for clean strings', () => { + expect(findInjectionCharCode('valid-name')).toBe(-1) + expect(findInjectionCharCode('my_package.v2')).toBe(-1) + }) + + it('should return the char code of the first injection character', () => { + expect(findInjectionCharCode('pkg|name')).toBe(0x7c) + expect(findInjectionCharCode('pkg$name')).toBe(0x24) + expect(findInjectionCharCode('pkg\x1bname')).toBe(0x1b) + }) + }) + + describe('formatInjectionChar', () => { + it('should format printable characters with quotes and hex', () => { + expect(formatInjectionChar(0x7c)).toBe('"|" (0x7c)') + expect(formatInjectionChar(0x24)).toBe('"$" (0x24)') + expect(formatInjectionChar(0x20)).toBe('" " (0x20)') + }) + + it('should format control characters as hex only', () => { + expect(formatInjectionChar(0x00)).toBe('0x00') + expect(formatInjectionChar(0x1b)).toBe('0x1b') + expect(formatInjectionChar(0x0a)).toBe('0x0a') + }) + + it('should format DEL as hex only', () => { + expect(formatInjectionChar(0x7f)).toBe('0x7f') + }) + }) }) diff --git a/test/purl-edge-cases.test.mts b/test/purl-edge-cases.test.mts index 8185de3..f62bb20 100644 --- a/test/purl-edge-cases.test.mts +++ b/test/purl-edge-cases.test.mts @@ -1966,7 +1966,7 @@ describe('Edge cases and additional coverage', () => { // Test name with whitespace expect( () => new PackageURL('cocoapods', null, 'Pod Name', null, null, null), - ).toThrow('cocoapods "name" component contains illegal characters') + ).toThrow('cocoapods "name" component contains injection character') // Test name with plus character expect( diff --git a/test/strings.test.mts b/test/strings.test.mts index fd08312..e397571 100644 --- a/test/strings.test.mts +++ b/test/strings.test.mts @@ -56,6 +56,22 @@ describe('String utilities', () => { expect(containsInjectionCharacters('a\nb')).toBe(true) expect(containsInjectionCharacters('a\rb')).toBe(true) }) + + it('should detect quote characters', () => { + expect(containsInjectionCharacters("a'b")).toBe(true) + expect(containsInjectionCharacters('a"b')).toBe(true) + }) + + it('should detect control characters', () => { + // NUL + expect(containsInjectionCharacters('a\x00b')).toBe(true) + // ESC (terminal escape sequences) + expect(containsInjectionCharacters('a\x1bb')).toBe(true) + // BEL + expect(containsInjectionCharacters('a\x07b')).toBe(true) + // DEL + expect(containsInjectionCharacters('a\x7fb')).toBe(true) + }) }) describe('isBlank', () => { From 8406e7eeba2d9b5c738a614f9f12571e6a6608bf Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 16:37:19 -0400 Subject: [PATCH 5/6] fix(security): freeze PurlInjectionError, use primordials, add unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Freeze PurlInjectionError instances (ObjectFreeze in constructor) and prototype — prevents property tampering on caught error objects - Replace raw String.fromCharCode, Number.prototype.toString, and String.prototype.padStart with captured primordials (StringFromCharCode, NumberPrototypeToString, StringPrototypePadStart) - Add unit tests verifying frozen instance, frozen prototype, and rejection of property writes/additions on error objects --- src/error.ts | 3 ++ src/primordials.ts | 8 ++++++ src/strings.ts | 8 ++++-- test/injection-validation.test.mts | 46 ++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/error.ts b/src/error.ts index 5ba12be..5bc6666 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,5 @@ import { + ObjectFreeze, StringPrototypeCharCodeAt, StringPrototypeSlice, StringPrototypeToLowerCase, @@ -74,7 +75,9 @@ class PurlInjectionError extends PurlError { this.charCode = charCode this.component = component this.purlType = purlType + ObjectFreeze(this) } } +ObjectFreeze(PurlInjectionError.prototype) export { formatPurlErrorMessage, PurlError, PurlInjectionError } diff --git a/src/primordials.ts b/src/primordials.ts index 43b7d4c..e97d334 100644 --- a/src/primordials.ts +++ b/src/primordials.ts @@ -80,11 +80,15 @@ const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor const ReflectOwnKeys = Reflect.ownKeys const ReflectSetPrototypeOf = Reflect.setPrototypeOf +// ─── Number ─────────────────────────────────────────────────────────── +const NumberPrototypeToString = uncurryThis(Number.prototype.toString) + // ─── RegExp ──────────────────────────────────────────────────────────── const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec) const RegExpPrototypeTest = uncurryThis(RegExp.prototype.test) // ─── String ──────────────────────────────────────────────────────────── +const StringFromCharCode = String.fromCharCode const StringPrototypeCharCodeAt = uncurryThis(String.prototype.charCodeAt) const StringPrototypeEndsWith = uncurryThis(String.prototype.endsWith) const StringPrototypeIncludes = uncurryThis(String.prototype.includes) @@ -98,6 +102,7 @@ const StringPrototypeReplaceAll = uncurryThis( replaceValue: string, ) => string, ) +const StringPrototypePadStart = uncurryThis(String.prototype.padStart) const StringPrototypeSlice = uncurryThis(String.prototype.slice) const StringPrototypeSplit = uncurryThis(String.prototype.split) const StringPrototypeStartsWith = uncurryThis(String.prototype.startsWith) @@ -125,6 +130,7 @@ export { MapCtor, ObjectCreate, ObjectEntries, + NumberPrototypeToString, ObjectFreeze, ObjectIsFrozen, ObjectKeys, @@ -137,6 +143,7 @@ export { RegExpPrototypeExec, RegExpPrototypeTest, SetCtor, + StringFromCharCode, StringPrototypeCharCodeAt, StringPrototypeEndsWith, StringPrototypeIncludes, @@ -144,6 +151,7 @@ export { StringPrototypeLastIndexOf, StringPrototypeReplace, StringPrototypeReplaceAll, + StringPrototypePadStart, StringPrototypeSlice, StringPrototypeSplit, StringPrototypeStartsWith, diff --git a/src/strings.ts b/src/strings.ts index a82c9f6..59201f4 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -3,10 +3,13 @@ * Includes whitespace detection, semver validation, locale comparison, and character replacement. */ import { + NumberPrototypeToString, ObjectFreeze, RegExpPrototypeTest, + StringFromCharCode, StringPrototypeCharCodeAt, StringPrototypeIndexOf, + StringPrototypePadStart, StringPrototypeSlice, StringPrototypeToLowerCase, } from './primordials.js' @@ -287,10 +290,11 @@ function containsInjectionCharacters(str: string): boolean { * Returns a string like `"|" (0x7c)` for printable chars or `0x1b` for control chars. */ function formatInjectionChar(code: number): string { + const hex = NumberPrototypeToString(code, 16) if (code >= 0x20 && code <= 0x7e) { - return `"${String.fromCharCode(code)}" (0x${code.toString(16)})` + return `"${StringFromCharCode(code)}" (0x${hex})` } - return `0x${code.toString(16).padStart(2, '0')}` + return `0x${StringPrototypePadStart(hex, 2, '0')}` } /** diff --git a/test/injection-validation.test.mts b/test/injection-validation.test.mts index 362ec0a..826c5c8 100644 --- a/test/injection-validation.test.mts +++ b/test/injection-validation.test.mts @@ -461,6 +461,52 @@ describe('Per-type injection character validation', () => { expect(err.charCode).toBe(0x1b) // ESC expect(err.message).toContain('0x1b') }) + + it('should have a frozen instance (properties cannot be tampered)', () => { + const err = getInjectionError( + () => + new PackageURL( + 'cargo', + undefined, + 'pkg|evil', + '1.0.0', + undefined, + undefined, + ), + ) + expect(Object.isFrozen(err)).toBe(true) + // Properties should not be writable + expect(() => { + ;(err as unknown as Record)['charCode'] = 0 + }).toThrow() + expect(() => { + ;(err as unknown as Record)['purlType'] = 'hacked' + }).toThrow() + expect(() => { + ;(err as unknown as Record)['component'] = 'hacked' + }).toThrow() + }) + + it('should have a frozen prototype', () => { + expect(Object.isFrozen(PurlInjectionError.prototype)).toBe(true) + }) + + it('should not allow adding new properties to instances', () => { + const err = getInjectionError( + () => + new PackageURL( + 'cargo', + undefined, + 'pkg|evil', + '1.0.0', + undefined, + undefined, + ), + ) + expect(() => { + ;(err as unknown as Record)['newProp'] = 'value' + }).toThrow() + }) }) describe('Hardened scanner - newly detected characters', () => { From 0eabc21964e33c3e49ca4c210e0ab032a74beda2 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 30 Mar 2026 17:03:04 -0400 Subject: [PATCH 6/6] fix: replace 8 raw built-in calls with captured primordials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate.ts: Array.isArray → ArrayIsArray, Object.keys → ObjectKeys - normalize.ts: Object.entries → ObjectEntries - vscode-extension.ts: JSON.stringify → JSONStringify - gem.ts: Array.isArray → ArrayIsArray - npm.ts: new Set() → new SetCtor() (2 instances) - primordials.ts: add NumberPrototypeToString, StringFromCharCode, StringPrototypePadStart exports --- src/normalize.ts | 3 ++- src/purl-types/gem.ts | 8 ++++++-- src/purl-types/npm.ts | 5 +++-- src/purl-types/vscode-extension.ts | 8 ++++++-- src/validate.ts | 6 ++++-- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/normalize.ts b/src/normalize.ts index 0aa5a7b..586839f 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -5,6 +5,7 @@ import { isObject } from './objects.js' import { ObjectCreate, + ObjectEntries, ObjectFreeze, ReflectApply, StringPrototypeCharCodeAt, @@ -152,7 +153,7 @@ function qualifiersToEntries( ? (ReflectApply(entriesProperty, rawQualifiersObj, []) as Iterable< [string, string] >) - : (Object.entries(rawQualifiers as Record) as Iterable< + : (ObjectEntries(rawQualifiers as Record) as Iterable< [string, string] >) } diff --git a/src/purl-types/gem.ts b/src/purl-types/gem.ts index 5356e0b..1a0df7c 100644 --- a/src/purl-types/gem.ts +++ b/src/purl-types/gem.ts @@ -5,7 +5,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' +import { + ArrayIsArray, + ArrayPrototypeSome, + StringPrototypeIncludes, +} from '../primordials.js' import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -75,7 +79,7 @@ export async function gemExists( const data = await httpJson>(url) - if (!Array.isArray(data) || data.length === 0) { + if (!ArrayIsArray(data) || data.length === 0) { return { exists: false, error: 'No versions found', diff --git a/src/purl-types/npm.ts b/src/purl-types/npm.ts index 33c2bef..31b48b6 100644 --- a/src/purl-types/npm.ts +++ b/src/purl-types/npm.ts @@ -9,6 +9,7 @@ import { encodeComponent } from '../encode.js' import { PurlError } from '../error.js' import { RegExpPrototypeTest, + SetCtor, StringPrototypeCharCodeAt, StringPrototypeIncludes, StringPrototypeIndexOf, @@ -132,7 +133,7 @@ const getNpmBuiltinSet = (() => { 'zlib', ] } - builtinSet = new Set(builtinNames) + builtinSet = new SetCtor(builtinNames) } return builtinSet } @@ -175,7 +176,7 @@ const getNpmLegacySet = (() => { ] } /* c8 ignore stop */ - legacySet = new Set(fullLegacyNames) + legacySet = new SetCtor(fullLegacyNames) } return legacySet } diff --git a/src/purl-types/vscode-extension.ts b/src/purl-types/vscode-extension.ts index a3d0501..79c6f70 100644 --- a/src/purl-types/vscode-extension.ts +++ b/src/purl-types/vscode-extension.ts @@ -9,7 +9,11 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' -import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' +import { + ArrayPrototypeSome, + JSONStringify, + StringPrototypeIncludes, +} from '../primordials.js' import { isSemverString, lowerName, @@ -196,7 +200,7 @@ export async function vscodeExtensionExists( 'Content-Type': 'application/json', Accept: 'application/json;api-version=7.1-preview.1', }, - body: JSON.stringify(requestBody), + body: JSONStringify(requestBody), }) const extensions = data.results?.[0]?.['extensions'] diff --git a/src/validate.ts b/src/validate.ts index 12681fa..efb16dd 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -5,6 +5,8 @@ import { PurlError, PurlInjectionError } from './error.js' import { isNullishOrEmptyString } from './lang.js' import { + ArrayIsArray, + ObjectKeys, ReflectApply, StringPrototypeCharCodeAt, StringPrototypeIncludes, @@ -204,7 +206,7 @@ function validateQualifiers( if (qualifiers === null || qualifiers === undefined) { return true } - if (typeof qualifiers !== 'object' || Array.isArray(qualifiers)) { + if (typeof qualifiers !== 'object' || ArrayIsArray(qualifiers)) { if (throws) { throw new PurlError('"qualifiers" must be a plain object') } @@ -218,7 +220,7 @@ function validateQualifiers( ( typeof keysProperty === 'function' ? ReflectApply(keysProperty, qualifiersObj, []) - : Object.keys(qualifiers as QualifiersObject) + : ObjectKeys(qualifiers as QualifiersObject) ) as Iterable // Use for-of to work with URLSearchParams#keys iterators // type-coverage:ignore-next-line -- TypeScript correctly infers the iteration type