Skip to content

Commit 64331f0

Browse files
authored
feat(security): add injection character validation to all purl type validators (#16)
* 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. * fix(test): reduce timeout to 15s * feat(security): add injection character validation to all purl type validators 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 * refactor(security): centralize injection validation, harden scanner, add PurlInjectionError 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) * fix(security): freeze PurlInjectionError, use primordials, add unit tests - 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 * fix: replace 8 raw built-in calls with captured primordials - 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
1 parent 2359689 commit 64331f0

37 files changed

Lines changed: 1213 additions & 160 deletions

src/error.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ObjectFreeze,
23
StringPrototypeCharCodeAt,
34
StringPrototypeSlice,
45
StringPrototypeToLowerCase,
@@ -46,4 +47,37 @@ class PurlError extends Error {
4647
}
4748
}
4849

49-
export { formatPurlErrorMessage, PurlError }
50+
/**
51+
* Specialized error for injection character detection.
52+
* Developers can catch this specifically to distinguish injection rejections
53+
* from other PURL validation errors and handle them at an elevated level
54+
* (e.g., logging, alerting, blocking).
55+
*
56+
* Properties:
57+
* - `component` — which PURL component was rejected ("name", "namespace")
58+
* - `charCode` — the character code of the injection character found
59+
* - `purlType` — the package type (e.g., "npm", "maven")
60+
*/
61+
class PurlInjectionError extends PurlError {
62+
readonly charCode: number
63+
readonly component: string
64+
readonly purlType: string
65+
66+
constructor(
67+
purlType: string,
68+
component: string,
69+
charCode: number,
70+
charLabel: string,
71+
) {
72+
super(
73+
`${purlType} "${component}" component contains injection character ${charLabel}`,
74+
)
75+
this.charCode = charCode
76+
this.component = component
77+
this.purlType = purlType
78+
ObjectFreeze(this)
79+
}
80+
}
81+
ObjectFreeze(PurlInjectionError.prototype)
82+
83+
export { formatPurlErrorMessage, PurlError, PurlInjectionError }

src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export {
8686
UrlConverter,
8787
} from './package-url.js'
8888
export { PurlBuilder } from './package-url-builder.js'
89-
export { PurlError } from './error.js'
89+
export { PurlError, PurlInjectionError } from './error.js'
9090
// ============================================================================
9191
// Modular Utilities
9292
// ============================================================================
@@ -96,7 +96,11 @@ export { parseNpmSpecifier } from './purl-types/npm.js'
9696
// separate entry point: import { npmExists } from '@socketregistry/packageurl-js/exists'
9797
// This keeps the core bundle lean (~200 KB vs 3.3 MB with HTTP deps).
9898
export type { ExistsOptions, ExistsResult } from './purl-types/npm.js'
99-
export { containsInjectionCharacters } from './strings.js'
99+
export {
100+
containsInjectionCharacters,
101+
findInjectionCharCode,
102+
formatInjectionChar,
103+
} from './strings.js'
100104
export { stringify, stringifySpec } from './stringify.js'
101105
export { Vers } from './vers.js'
102106
export type { VersComparator, VersConstraint, VersWildcard } from './vers.js'

src/normalize.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { isObject } from './objects.js'
66
import {
77
ObjectCreate,
8+
ObjectEntries,
89
ObjectFreeze,
910
ReflectApply,
1011
StringPrototypeCharCodeAt,
@@ -152,7 +153,7 @@ function qualifiersToEntries(
152153
? (ReflectApply(entriesProperty, rawQualifiersObj, []) as Iterable<
153154
[string, string]
154155
>)
155-
: (Object.entries(rawQualifiers as Record<string, string>) as Iterable<
156+
: (ObjectEntries(rawQualifiers as Record<string, string>) as Iterable<
156157
[string, string]
157158
>)
158159
}

src/primordials.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,15 @@ const ReflectGetOwnPropertyDescriptor = Reflect.getOwnPropertyDescriptor
8080
const ReflectOwnKeys = Reflect.ownKeys
8181
const ReflectSetPrototypeOf = Reflect.setPrototypeOf
8282

83+
// ─── Number ───────────────────────────────────────────────────────────
84+
const NumberPrototypeToString = uncurryThis(Number.prototype.toString)
85+
8386
// ─── RegExp ────────────────────────────────────────────────────────────
8487
const RegExpPrototypeExec = uncurryThis(RegExp.prototype.exec)
8588
const RegExpPrototypeTest = uncurryThis(RegExp.prototype.test)
8689

8790
// ─── String ────────────────────────────────────────────────────────────
91+
const StringFromCharCode = String.fromCharCode
8892
const StringPrototypeCharCodeAt = uncurryThis(String.prototype.charCodeAt)
8993
const StringPrototypeEndsWith = uncurryThis(String.prototype.endsWith)
9094
const StringPrototypeIncludes = uncurryThis(String.prototype.includes)
@@ -98,6 +102,7 @@ const StringPrototypeReplaceAll = uncurryThis(
98102
replaceValue: string,
99103
) => string,
100104
)
105+
const StringPrototypePadStart = uncurryThis(String.prototype.padStart)
101106
const StringPrototypeSlice = uncurryThis(String.prototype.slice)
102107
const StringPrototypeSplit = uncurryThis(String.prototype.split)
103108
const StringPrototypeStartsWith = uncurryThis(String.prototype.startsWith)
@@ -125,6 +130,7 @@ export {
125130
MapCtor,
126131
ObjectCreate,
127132
ObjectEntries,
133+
NumberPrototypeToString,
128134
ObjectFreeze,
129135
ObjectIsFrozen,
130136
ObjectKeys,
@@ -137,13 +143,15 @@ export {
137143
RegExpPrototypeExec,
138144
RegExpPrototypeTest,
139145
SetCtor,
146+
StringFromCharCode,
140147
StringPrototypeCharCodeAt,
141148
StringPrototypeEndsWith,
142149
StringPrototypeIncludes,
143150
StringPrototypeIndexOf,
144151
StringPrototypeLastIndexOf,
145152
StringPrototypeReplace,
146153
StringPrototypeReplaceAll,
154+
StringPrototypePadStart,
147155
StringPrototypeSlice,
148156
StringPrototypeSplit,
149157
StringPrototypeStartsWith,

src/purl-type.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@
66
* module in the purl-types/ directory with specific rules for namespace, name, version
77
* normalization and validation.
88
*/
9+
import { PurlInjectionError } from './error.js'
910
import { createHelpersNamespaceObject } from './helpers.js'
11+
import { findInjectionCharCode, formatInjectionChar } from './strings.js'
1012
import { normalize as alpmNormalize } from './purl-types/alpm.js'
1113
import { normalize as apkNormalize } from './purl-types/apk.js'
1214
import {
1315
normalize as bazelNormalize,
1416
validate as bazelValidate,
1517
} from './purl-types/bazel.js'
16-
import { normalize as bitbucketNormalize } from './purl-types/bitbucket.js'
18+
import {
19+
normalize as bitbucketNormalize,
20+
validate as bitbucketValidate,
21+
} from './purl-types/bitbucket.js'
1722
import { normalize as bitnamiNormalize } from './purl-types/bitnami.js'
1823
import { validate as cargoValidate } from './purl-types/cargo.js'
1924
import { validate as cocoaodsValidate } from './purl-types/cocoapods.js'
@@ -26,13 +31,25 @@ import {
2631
import { validate as cpanValidate } from './purl-types/cpan.js'
2732
import { validate as cranValidate } from './purl-types/cran.js'
2833
import { normalize as debNormalize } from './purl-types/deb.js'
29-
import { normalize as dockerNormalize } from './purl-types/docker.js'
34+
import {
35+
normalize as dockerNormalize,
36+
validate as dockerValidate,
37+
} from './purl-types/docker.js'
3038
import { validate as gemValidate } from './purl-types/gem.js'
3139
import { normalize as genericNormalize } from './purl-types/generic.js'
32-
import { normalize as githubNormalize } from './purl-types/github.js'
33-
import { normalize as gitlabNormalize } from './purl-types/gitlab.js'
40+
import {
41+
normalize as githubNormalize,
42+
validate as githubValidate,
43+
} from './purl-types/github.js'
44+
import {
45+
normalize as gitlabNormalize,
46+
validate as gitlabValidate,
47+
} from './purl-types/gitlab.js'
3448
import { validate as golangValidate } from './purl-types/golang.js'
35-
import { normalize as hexNormalize } from './purl-types/hex.js'
49+
import {
50+
normalize as hexNormalize,
51+
validate as hexValidate,
52+
} from './purl-types/hex.js'
3653
import { normalize as huggingfaceNormalize } from './purl-types/huggingface.js'
3754
import {
3855
normalize as juliaNormalize,
@@ -62,7 +79,10 @@ import {
6279
normalize as pubNormalize,
6380
validate as pubValidate,
6481
} from './purl-types/pub.js'
65-
import { normalize as pypiNormalize } from './purl-types/pypi.js'
82+
import {
83+
normalize as pypiNormalize,
84+
validate as pypiValidate,
85+
} from './purl-types/pypi.js'
6686
import { normalize as qpkgNormalize } from './purl-types/qpkg.js'
6787
import { normalize as rpmNormalize } from './purl-types/rpm.js'
6888
import { normalize as socketNormalize } from './purl-types/socket.js'
@@ -94,8 +114,40 @@ const PurlTypNormalizer = (purl: PurlObject): PurlObject => purl
94114

95115
/**
96116
* Default validator for PURL types without specific validation rules.
117+
* Rejects injection characters in name and namespace components.
118+
* This ensures all types (including newly added ones) get injection
119+
* protection by default — security is opt-out, not opt-in.
97120
*/
98-
const PurlTypeValidator = (_purl: PurlObject, _throws: boolean): boolean => true
121+
function PurlTypeValidator(purl: PurlObject, throws: boolean): boolean {
122+
const type = purl.type ?? 'unknown'
123+
if (typeof purl.namespace === 'string') {
124+
const nsCode = findInjectionCharCode(purl.namespace)
125+
if (nsCode !== -1) {
126+
if (throws) {
127+
throw new PurlInjectionError(
128+
type,
129+
'namespace',
130+
nsCode,
131+
formatInjectionChar(nsCode),
132+
)
133+
}
134+
return false
135+
}
136+
}
137+
const nameCode = findInjectionCharCode(purl.name)
138+
if (nameCode !== -1) {
139+
if (throws) {
140+
throw new PurlInjectionError(
141+
type,
142+
'name',
143+
nameCode,
144+
formatInjectionChar(nameCode),
145+
)
146+
}
147+
return false
148+
}
149+
return true
150+
}
99151

100152
// PURL types:
101153
// https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst
@@ -133,14 +185,19 @@ const PurlType = createHelpersNamespaceObject(
133185
},
134186
validate: {
135187
bazel: bazelValidate,
188+
bitbucket: bitbucketValidate,
136189
cargo: cargoValidate,
137190
cocoapods: cocoaodsValidate,
138191
conda: condaValidate,
139192
conan: conanValidate,
140193
cpan: cpanValidate,
141194
cran: cranValidate,
195+
docker: dockerValidate,
142196
gem: gemValidate,
197+
github: githubValidate,
198+
gitlab: gitlabValidate,
143199
golang: golangValidate,
200+
hex: hexValidate,
144201
julia: juliaValidate,
145202
maven: mavenValidate,
146203
mlflow: mlflowValidate,
@@ -150,6 +207,7 @@ const PurlType = createHelpersNamespaceObject(
150207
opam: opamValidate,
151208
otp: otpValidate,
152209
pub: pubValidate,
210+
pypi: pypiValidate,
153211
swift: swiftValidate,
154212
swid: swidValidate,
155213
'vscode-extension': vscodeExtensionValidate,

src/purl-types/bazel.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { PurlError } from '../error.js'
1010
import { lowerName } from '../strings.js'
11+
import { validateNoInjectionByType } from '../validate.js'
1112

1213
interface PurlObject {
1314
name: string
@@ -29,7 +30,8 @@ export function normalize(purl: PurlObject): PurlObject {
2930

3031
/**
3132
* Validate Bazel package URL.
32-
* Bazel packages must have a version (for reproducible builds).
33+
* Bazel packages must have a version (for reproducible builds). Name must not
34+
* contain injection characters.
3335
*/
3436
export function validate(purl: PurlObject, throws: boolean): boolean {
3537
if (!purl.version || purl.version.length === 0) {
@@ -38,5 +40,8 @@ export function validate(purl: PurlObject, throws: boolean): boolean {
3840
}
3941
return false
4042
}
43+
if (!validateNoInjectionByType('bazel', 'name', purl.name, throws)) {
44+
return false
45+
}
4146
return true
4247
}

src/purl-types/bitbucket.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
2-
* @fileoverview Bitbucket PURL normalization.
2+
* @fileoverview Bitbucket PURL normalization and validation.
33
* https://github.com/package-url/purl-spec/blob/master/PURL-TYPES.rst#bitbucket
44
*/
55

66
import { lowerName, lowerNamespace } from '../strings.js'
7+
import { validateNoInjectionByType } from '../validate.js'
78

89
interface PurlObject {
910
name: string
@@ -23,3 +24,19 @@ export function normalize(purl: PurlObject): PurlObject {
2324
lowerName(purl)
2425
return purl
2526
}
27+
28+
/**
29+
* Validate Bitbucket package URL.
30+
* Name and namespace must not contain injection characters.
31+
*/
32+
export function validate(purl: PurlObject, throws: boolean): boolean {
33+
if (
34+
!validateNoInjectionByType('bitbucket', 'namespace', purl.namespace, throws)
35+
) {
36+
return false
37+
}
38+
if (!validateNoInjectionByType('bitbucket', 'name', purl.name, throws)) {
39+
return false
40+
}
41+
return true
42+
}

src/purl-types/cargo.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { httpJson } from '@socketsecurity/lib/http-request'
77

88
import { ArrayPrototypeSome, StringPrototypeIncludes } from '../primordials.js'
9-
import { validateEmptyByType } from '../validate.js'
9+
import { validateEmptyByType, validateNoInjectionByType } from '../validate.js'
1010

1111
import type { ExistsResult, ExistsOptions } from './npm.js'
1212

@@ -137,10 +137,18 @@ export async function cargoExists(
137137

138138
/**
139139
* Validate Cargo package URL.
140-
* Cargo packages must not have a namespace.
140+
* Cargo packages must not have a namespace. Name must not contain injection characters.
141141
*/
142142
export function validate(purl: PurlObject, throws: boolean): boolean {
143-
return validateEmptyByType('cargo', 'namespace', purl.namespace, {
144-
throws,
145-
})
143+
if (
144+
!validateEmptyByType('cargo', 'namespace', purl.namespace, {
145+
throws,
146+
})
147+
) {
148+
return false
149+
}
150+
if (!validateNoInjectionByType('cargo', 'name', purl.name, throws)) {
151+
return false
152+
}
153+
return true
146154
}

src/purl-types/cocoapods.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { httpJson } from '@socketsecurity/lib/http-request'
77

88
import { PurlError } from '../error.js'
99
import {
10-
RegExpPrototypeTest,
1110
StringPrototypeCharCodeAt,
1211
StringPrototypeIncludes,
1312
ArrayPrototypeSome,
1413
} from '../primordials.js'
14+
import { validateNoInjectionByType } from '../validate.js'
1515

1616
import type { ExistsResult, ExistsOptions } from './npm.js'
1717

@@ -125,17 +125,13 @@ export async function cocoapodsExists(
125125

126126
/**
127127
* Validate CocoaPods package URL.
128-
* Name cannot contain whitespace, plus (+) character, or begin with a period (.).
128+
* Name cannot contain injection or whitespace characters, plus (+) character,
129+
* or begin with a period (.).
129130
*/
130131
export function validate(purl: PurlObject, throws: boolean): boolean {
131132
const { name } = purl
132-
// Name cannot contain whitespace
133-
if (RegExpPrototypeTest(/\s/, name)) {
134-
if (throws) {
135-
throw new PurlError(
136-
'cocoapods "name" component cannot contain whitespace',
137-
)
138-
}
133+
// Name must not contain injection characters
134+
if (!validateNoInjectionByType('cocoapods', 'name', name, throws)) {
139135
return false
140136
}
141137
// Name cannot contain a plus (+) character

0 commit comments

Comments
 (0)