diff --git a/src/error.ts b/src/error.ts index 8f9f582..5bc6666 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,4 +1,5 @@ import { + ObjectFreeze, StringPrototypeCharCodeAt, StringPrototypeSlice, StringPrototypeToLowerCase, @@ -46,4 +47,37 @@ 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 + ObjectFreeze(this) + } +} +ObjectFreeze(PurlInjectionError.prototype) + +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/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/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/purl-type.ts b/src/purl-type.ts index c1730e0..351c7dd 100644 --- a/src/purl-type.ts +++ b/src/purl-type.ts @@ -6,14 +6,19 @@ * 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 { 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 +31,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 +79,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' @@ -94,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 @@ -133,14 +185,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 +207,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..4bab260 100644 --- a/src/purl-types/bazel.ts +++ b/src/purl-types/bazel.ts @@ -8,6 +8,7 @@ import { PurlError } from '../error.js' import { lowerName } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -29,7 +30,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 +40,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + 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 3766f66..1a1df29 100644 --- a/src/purl-types/bitbucket.ts +++ b/src/purl-types/bitbucket.ts @@ -1,9 +1,10 @@ /** - * @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 { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -23,3 +24,19 @@ 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 ( + !validateNoInjectionByType('bitbucket', 'namespace', purl.namespace, throws) + ) { + return false + } + 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 5089858..c8ebbfb 100644 --- a/src/purl-types/cargo.ts +++ b/src/purl-types/cargo.ts @@ -6,7 +6,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -137,10 +137,18 @@ 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 (!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 3615042..b96f5af 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 { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -125,17 +125,13 @@ 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)) { - if (throws) { - throw new PurlError( - 'cocoapods "name" component cannot contain whitespace', - ) - } + // Name must not contain injection 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 2d2e502..5d840db 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 { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -38,5 +39,13 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + if ( + !validateNoInjectionByType('conan', 'namespace', purl.namespace, throws) + ) { + return false + } + 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 2cd73b6..151bf95 100644 --- a/src/purl-types/conda.ts +++ b/src/purl-types/conda.ts @@ -10,7 +10,7 @@ import { StringPrototypeIncludes, } from '../primordials.js' import { lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -34,12 +34,20 @@ 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 (!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 a8ce33f..288058c 100644 --- a/src/purl-types/cpan.ts +++ b/src/purl-types/cpan.ts @@ -10,6 +10,7 @@ import { StringPrototypeIncludes, StringPrototypeToUpperCase, } from '../primordials.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -129,5 +130,11 @@ export function validate(purl: PurlObject, throws: boolean): boolean { } return false } + if (!validateNoInjectionByType('cpan', 'namespace', namespace, throws)) { + return false + } + 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 ea39c3d..8c2c5b8 100644 --- a/src/purl-types/cran.ts +++ b/src/purl-types/cran.ts @@ -9,7 +9,10 @@ import { ArrayPrototypeIncludes, StringPrototypeIncludes, } from '../primordials.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -119,10 +122,18 @@ 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 (!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 aa7317c..683de28 100644 --- a/src/purl-types/docker.ts +++ b/src/purl-types/docker.ts @@ -7,6 +7,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { StringPrototypeIncludes } from '../primordials.js' import { lowerName } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsOptions, ExistsResult } from './npm.js' @@ -28,6 +29,22 @@ 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 ( + !validateNoInjectionByType('docker', 'namespace', purl.namespace, throws) + ) { + return false + } + if (!validateNoInjectionByType('docker', 'name', purl.name, throws)) { + 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..1a0df7c 100644 --- a/src/purl-types/gem.ts +++ b/src/purl-types/gem.ts @@ -5,8 +5,12 @@ import { httpJson } from '@socketsecurity/lib/http-request' -import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' -import { validateEmptyByType } from '../validate.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', @@ -131,10 +135,18 @@ 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 (!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 ce257c3..f2b3c4a 100644 --- a/src/purl-types/github.ts +++ b/src/purl-types/github.ts @@ -1,9 +1,10 @@ /** - * @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 { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -23,3 +24,19 @@ 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 ( + !validateNoInjectionByType('github', 'namespace', purl.namespace, throws) + ) { + return false + } + 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 8a153b5..5c01a89 100644 --- a/src/purl-types/gitlab.ts +++ b/src/purl-types/gitlab.ts @@ -1,9 +1,10 @@ /** - * @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 { validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -23,3 +24,19 @@ 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 ( + !validateNoInjectionByType('gitlab', 'namespace', purl.namespace, throws) + ) { + return false + } + 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 0b40490..c571a3e 100644 --- a/src/purl-types/golang.ts +++ b/src/purl-types/golang.ts @@ -41,6 +41,7 @@ import { StringPrototypeToLowerCase, } from '../primordials.js' import { isSemverString } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -168,9 +169,18 @@ 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 ( + !validateNoInjectionByType('golang', 'namespace', purl.namespace, throws) + ) { + return false + } + if (!validateNoInjectionByType('golang', 'name', purl.name, throws)) { + 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..0aabec0 100644 --- a/src/purl-types/hex.ts +++ b/src/purl-types/hex.ts @@ -7,6 +7,7 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { lowerName, lowerNamespace } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -123,3 +124,17 @@ 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 (!validateNoInjectionByType('hex', 'namespace', purl.namespace, throws)) { + return false + } + 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 068b15c..9c7c0f4 100644 --- a/src/purl-types/julia.ts +++ b/src/purl-types/julia.ts @@ -6,7 +6,7 @@ * Package names are case-sensitive and typically CamelCase. */ -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -30,7 +30,15 @@ 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 (!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 e4c5067..9dc9712 100644 --- a/src/purl-types/maven.ts +++ b/src/purl-types/maven.ts @@ -6,7 +6,10 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { StringPrototypeIncludes } from '../primordials.js' -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -133,10 +136,24 @@ 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 ( + !validateNoInjectionByType('maven', 'namespace', purl.namespace, throws) + ) { + return false + } + 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 78a11f8..0d828ca 100644 --- a/src/purl-types/mlflow.ts +++ b/src/purl-types/mlflow.ts @@ -5,7 +5,7 @@ import { StringPrototypeIncludes } from '../primordials.js' import { lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -33,7 +33,15 @@ 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 (!validateNoInjectionByType('mlflow', 'name', purl.name, throws)) { + return false + } + return true } 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/nuget.ts b/src/purl-types/nuget.ts index 9c75f47..4a862e7 100644 --- a/src/purl-types/nuget.ts +++ b/src/purl-types/nuget.ts @@ -11,7 +11,7 @@ import { StringPrototypeIncludes, StringPrototypeToLowerCase, } from '../primordials.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -143,10 +143,18 @@ 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 (!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 1092ae5..d6c92a4 100644 --- a/src/purl-types/oci.ts +++ b/src/purl-types/oci.ts @@ -4,7 +4,7 @@ */ import { lowerName, lowerVersion } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -30,7 +30,15 @@ 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 (!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 fdcde03..2dad8eb 100644 --- a/src/purl-types/opam.ts +++ b/src/purl-types/opam.ts @@ -5,7 +5,7 @@ * OPAM is the OCaml package manager. Package names are lowercase. */ -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -21,7 +21,15 @@ 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 (!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 db9fb30..75dc1d9 100644 --- a/src/purl-types/otp.ts +++ b/src/purl-types/otp.ts @@ -7,7 +7,7 @@ */ import { lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -32,7 +32,15 @@ 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 (!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 3a58011..8b2afa9 100644 --- a/src/purl-types/pypi.ts +++ b/src/purl-types/pypi.ts @@ -12,6 +12,7 @@ import { lowerVersion, replaceUnderscoresWithDashes, } from '../strings.js' +import { validateNoInjectionByType } from '../validate.js' import type { ExistsResult, ExistsOptions } from './npm.js' @@ -37,6 +38,17 @@ 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 (!validateNoInjectionByType('pypi', 'name', purl.name, throws)) { + 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..289ae2e 100644 --- a/src/purl-types/swid.ts +++ b/src/purl-types/swid.ts @@ -10,6 +10,7 @@ import { StringPrototypeToLowerCase, StringPrototypeTrim, } from '../primordials.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, @@ -59,5 +60,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean { return false } } + 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 d10cd56..c2de5fe 100644 --- a/src/purl-types/swift.ts +++ b/src/purl-types/swift.ts @@ -3,7 +3,10 @@ * https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#swift */ -import { validateRequiredByType } from '../validate.js' +import { + validateNoInjectionByType, + validateRequiredByType, +} from '../validate.js' interface PurlObject { name: string @@ -16,12 +19,27 @@ 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 ( + !validateNoInjectionByType('swift', 'namespace', purl.namespace, throws) + ) { + return false + } + 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..79c6f70 100644 --- a/src/purl-types/vscode-extension.ts +++ b/src/purl-types/vscode-extension.ts @@ -9,15 +9,21 @@ import { httpJson } from '@socketsecurity/lib/http-request' import { PurlError } from '../error.js' -import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js' import { - containsInjectionCharacters, + ArrayPrototypeSome, + JSONStringify, + StringPrototypeIncludes, +} from '../primordials.js' +import { 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 +64,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 +92,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 @@ -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/purl-types/yocto.ts b/src/purl-types/yocto.ts index c06db6c..6f261b6 100644 --- a/src/purl-types/yocto.ts +++ b/src/purl-types/yocto.ts @@ -7,7 +7,7 @@ */ import { lowerName } from '../strings.js' -import { validateEmptyByType } from '../validate.js' +import { validateEmptyByType, validateNoInjectionByType } from '../validate.js' interface PurlObject { name: string @@ -32,7 +32,15 @@ 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 (!validateNoInjectionByType('yocto', 'name', purl.name, throws)) { + return false + } + return true } diff --git a/src/strings.ts b/src/strings.ts index b3abf23..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' @@ -181,55 +184,117 @@ 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 { + const hex = NumberPrototypeToString(code, 16) + if (code >= 0x20 && code <= 0x7e) { + return `"${StringFromCharCode(code)}" (0x${hex})` + } + return `0x${StringPrototypePadStart(hex, 2, '0')}` } /** @@ -245,6 +310,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..efb16dd 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -2,14 +2,20 @@ * @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 { + ArrayIsArray, + ObjectKeys, 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 +40,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. @@ -169,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') } @@ -183,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 @@ -390,6 +427,7 @@ export { validateEmptyByType, validateName, validateNamespace, + validateNoInjectionByType, validateQualifiers, validateQualifierKey, validateRequired, diff --git a/test/injection-validation.test.mts b/test/injection-validation.test.mts new file mode 100644 index 0000000..826c5c8 --- /dev/null +++ b/test/injection-validation.test.mts @@ -0,0 +1,566 @@ +/** + * @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, 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 = ['|', '&', ';', '`', '$'] + +// 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) + }) + }) + + 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') + }) + + 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', () => { + 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 bb9d2fe..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 cannot contain whitespace') + ).toThrow('cocoapods "name" component contains injection character') // Test name with plus character expect( @@ -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') - }) + }, 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') - }) + }, 15_000) }) describe('UrlConverter edge cases', () => { 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', () => {