Skip to content

fix: address decodeURIComponent errors #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

const LOOP_SENTINEL = 1_000_000

const REUSED_SEARCH_PARAMS = new URLSearchParams()

const REUSED_SEARCH_PARAMS_KEY = '_'

const REUSED_SEARCH_PARAMS_OFFSET = 2 // '_='.length

module.exports = {
LOOP_SENTINEL
LOOP_SENTINEL,
REUSED_SEARCH_PARAMS,
REUSED_SEARCH_PARAMS_KEY,
REUSED_SEARCH_PARAMS_OFFSET
}
16 changes: 16 additions & 0 deletions src/decode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use strict'

const { PurlError } = require('./error')

const { decodeURIComponent } = globalThis

function decodePurlComponent(comp, encodedURIComponent) {
try {
return decodeURIComponent(encodedURIComponent)
} catch {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little worried about this. If somebody writes pkg:generic/whatever#100% it will do what they want, but if somebody writes pkg:generic/whatever#100%/100%25, the subpath will be 100%/100%25 because the decoding error applies to the entire component.

Implementations are inconsistent.

  • error: anchore/packageurl-go, package-url/packageurl-go, package-url/packageurl-java, package-url/packageurl-js (2.0.0), sonatype/package-url-java
  • 100%/100%: althonos/packageurl.rs, giterlizzi/perl-URL-PackageURL, package-url/packageurl-dotnet, package-url/packageurl-php, package-url/packageurl-python, package-url/packageurl-swift, phylum-dev/purl
  • 100%/100%25: maennchen/purl, package-url/packageurl-ruby, package-url/packageurl-js (this code)

It probably doesn't matter because it's an invalid PURL to begin with.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matt-phylum Thank you for digging into this. It is interesting.

new URL('pkg:generic/whatever#100%/100%25').toString()
// -> 'pkg:generic/whatever#100%/100%25'

While

PackageURL.fromString('pkg:generic/whatever#100%/100%25').toString()
-> 'pkg:generic/whatever#100%25/100%2525'

Updated PR to error:

PurlError: Invalid purl: unable to decode "subpath" component

throw new PurlError(`unable to decode "${comp}" component`)
}

module.exports = {
decodePurlComponent
}
13 changes: 7 additions & 6 deletions src/encode.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use strict'

const {
REUSED_SEARCH_PARAMS,
REUSED_SEARCH_PARAMS_KEY,
REUSED_SEARCH_PARAMS_OFFSET
} = require('./constants')
const { isObject } = require('./objects')
const { isNonEmptyString } = require('./strings')

const reusedSearchParams = new URLSearchParams()
const reusedSearchParamKey = '_'
const reusedSearchParamOffset = 2 // '_='.length

const { encodeURIComponent } = globalThis

function encodeNamespace(namespace) {
Expand All @@ -22,9 +23,9 @@ function encodeQualifierParam(param) {
// Param key and value are encoded with `percentEncodeSet` of
// 'application/x-www-form-urlencoded' and `spaceAsPlus` of `true`.
// https://url.spec.whatwg.org/#urlencoded-serializing
reusedSearchParams.set(reusedSearchParamKey, param)
REUSED_SEARCH_PARAMS.set(REUSED_SEARCH_PARAMS_KEY, param)
return replacePlusSignWithPercentEncodedSpace(
reusedSearchParams.toString().slice(reusedSearchParamOffset)
REUSED_SEARCH_PARAMS.toString().slice(REUSED_SEARCH_PARAMS_OFFSET)
)
}
return ''
Expand Down
34 changes: 34 additions & 0 deletions src/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict'

function formatPurlErrorMessage(message = '') {
const { length } = message
let formatted = ''
if (length) {
// Lower case start of message.
const code0 = message.charCodeAt(0)
formatted =
code0 >= 65 /*'A'*/ || code0 <= 90 /*'Z'*/
? `${message[0].toLowerCase()}${message.slice(1)}`
: message
// Remove period from end of message.
if (
length > 1 &&
message.charCodeAt(length - 1) === 46 /*'.'*/ &&
message.charCodeAt(length - 2) !== 46
) {
formatted = formatted.slice(0, -1)
}
}
return `Invalid purl: ${formatted}`
}

class PurlError extends Error {
constructor(message) {
super(formatPurlErrorMessage(message))
}
}

module.exports = {
formatPurlErrorMessage,
PurlError
}
16 changes: 5 additions & 11 deletions src/normalize.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,13 @@
const { isObject } = require('./objects')
const { isBlank } = require('./strings')

const { decodeURIComponent } = globalThis

function normalizeName(rawName) {
return typeof rawName === 'string'
? decodeURIComponent(rawName).trim()
: undefined
return typeof rawName === 'string' ? rawName.trim() : undefined
}

function normalizeNamespace(rawNamespace) {
return typeof rawNamespace === 'string'
? normalizePath(decodeURIComponent(rawNamespace))
? normalizePath(rawNamespace)
: undefined
}

Expand Down Expand Up @@ -74,22 +70,20 @@ function normalizeQualifiers(rawQualifiers) {

function normalizeSubpath(rawSubpath) {
return typeof rawSubpath === 'string'
? normalizePath(decodeURIComponent(rawSubpath), subpathFilter)
? normalizePath(rawSubpath, subpathFilter)
: undefined
}

function normalizeType(rawType) {
// The type must NOT be percent-encoded.
// The type is case insensitive. The canonical form is lowercase.
return typeof rawType === 'string'
? decodeURIComponent(rawType).trim().toLowerCase()
? rawType.trim().toLowerCase()
: undefined
}

function normalizeVersion(rawVersion) {
return typeof rawVersion === 'string'
? decodeURIComponent(rawVersion).trim()
: undefined
return typeof rawVersion === 'string' ? rawVersion.trim() : undefined
}

function qualifiersToEntries(rawQualifiers) {
Expand Down
38 changes: 25 additions & 13 deletions src/package-url.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ SOFTWARE.
*/
'use strict'

const { decodePurlComponent } = require('./decode')
const { isObject, recursiveFreeze } = require('./objects')
const { isBlank, isNonEmptyString, trimLeadingSlashes } = require('./strings')

const { PurlComponent } = require('./purl-component')
const { PurlQualifierNames } = require('./purl-qualifier-names')
const { PurlType } = require('./purl-type')
const { PurlError } = require('./error')

class PackageURL {
static Component = recursiveFreeze(PurlComponent)
Expand Down Expand Up @@ -148,34 +150,32 @@ class PackageURL {
? url
: new URL(purlStr)
} catch (e) {
throw new Error('Invalid purl: failed to parse as URL', {
throw new PurlError('failed to parse as URL', {
cause: e
})
}
}
// The scheme is a constant with the value "pkg".
if (url?.protocol !== 'pkg:') {
throw new Error(
'Invalid purl: missing required "pkg" scheme component'
)
throw new PurlError('missing required "pkg" scheme component')
}
// A purl must NOT contain a URL Authority i.e. there is no support for
// username, password, host and port components.
if (
maybeUrlWithAuth.username !== '' ||
maybeUrlWithAuth.password !== ''
) {
throw new Error(
'Invalid purl: cannot contain a "user:pass@host:port"'
)
throw new PurlError('cannot contain a "user:pass@host:port"')
}

const { pathname } = url
const firstSlashIndex = pathname.indexOf('/')
const rawType =
const rawType = decodePurlComponent(
'type',
firstSlashIndex === -1
? pathname
: pathname.slice(0, firstSlashIndex)
)
if (firstSlashIndex < 1) {
return [
rawType,
Expand Down Expand Up @@ -204,25 +204,37 @@ class PackageURL {
)
if (atSignIndex !== -1) {
// Split the remainder once from right on '@'.
rawVersion = pathname.slice(atSignIndex + 1)
rawVersion = decodePurlComponent(
'version',
pathname.slice(atSignIndex + 1)
)
}

let rawNamespace
let rawName
const lastSlashIndex = beforeVersion.lastIndexOf('/')
if (lastSlashIndex === -1) {
// Split the remainder once from right on '/'.
rawName = beforeVersion
rawName = decodePurlComponent('name', beforeVersion)
} else {
// Split the remainder once from right on '/'.
rawName = beforeVersion.slice(lastSlashIndex + 1)
rawName = decodePurlComponent(
'name',
beforeVersion.slice(lastSlashIndex + 1)
)
// Split the remainder on '/'.
rawNamespace = beforeVersion.slice(0, lastSlashIndex)
rawNamespace = decodePurlComponent(
'namespace',
beforeVersion.slice(0, lastSlashIndex)
)
}

let rawQualifiers
const { searchParams } = url
if (searchParams.size !== 0) {
searchParams.forEach((value) =>
decodePurlComponent('qualifiers', value)
)
// Split the remainder once from right on '?'.
rawQualifiers = searchParams
}
Expand All @@ -231,7 +243,7 @@ class PackageURL {
const { hash } = url
if (hash.length !== 0) {
// Split the purl string once from right on '#'.
rawSubpath = hash.slice(1)
rawSubpath = decodePurlComponent('subpath', hash.slice(1))
}

return [
Expand Down
17 changes: 9 additions & 8 deletions src/purl-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
} = require('./strings')

const { validateEmptyByType, validateRequiredByType } = require('./validate')
const { PurlError } = require('./error')

const PurlTypNormalizer = (purl) => purl

Expand Down Expand Up @@ -149,16 +150,16 @@ module.exports = {
if (isNullishOrEmptyString(purl.namespace)) {
if (purl.qualifiers?.channel) {
if (throws) {
throw new Error(
'Invalid purl: conan requires a "namespace" field when a "channel" qualifier is present.'
throw new PurlError(
'conan requires a "namespace" component when a "channel" qualifier is present'
)
}
return false
}
} else if (isNullishOrEmptyString(purl.qualifiers)) {
if (throws) {
throw new Error(
'Invalid purl: conan requires a "qualifiers" field when a namespace is present.'
throw new PurlError(
'conan requires a "qualifiers" component when a namespace is present'
)
}
return false
Expand Down Expand Up @@ -190,8 +191,8 @@ module.exports = {
!isSemverString(version.slice(1))
) {
if (throws) {
throw new Error(
'Invalid purl: golang "version" field starting with a "v" must be followed by a valid semver version'
throw new PurlError(
'golang "version" component starting with a "v" must be followed by a valid semver version'
)
}
return false
Expand Down Expand Up @@ -241,8 +242,8 @@ module.exports = {
)
) {
if (throws) {
throw new Error(
'Invalid purl: pub "name" field may only contain [a-z0-9_] characters'
throw new PurlError(
'pub "name" component may only contain [a-z0-9_] characters'
)
}
return false
Expand Down
27 changes: 12 additions & 15 deletions src/validate.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
'use strict'

const { PurlError } = require('./error')
const { isNullishOrEmptyString } = require('./lang')
const { isNonEmptyString } = require('./strings')

function validateEmptyByType(type, name, value, throws) {
if (!isNullishOrEmptyString(value)) {
if (throws) {
throw new Error(
`Invalid purl: ${type} "${name}" field must be empty.`
)
throw new PurlError(`${type} "${name}" component must be empty`)
}
return false
}
Expand All @@ -32,9 +31,7 @@ function validateQualifiers(qualifiers, throws) {
}
if (typeof qualifiers !== 'object') {
if (throws) {
throw new Error(
'Invalid purl: "qualifiers" argument must be an object.'
)
throw new PurlError('"qualifiers" must be an object')
}
return false
}
Expand Down Expand Up @@ -74,8 +71,8 @@ function validateQualifierKey(key, throws) {
)
) {
if (throws) {
throw new Error(
`Invalid purl: qualifier "${key}" contains an illegal character.`
throw new PurlError(
`qualifier "${key}" contains an illegal character`
)
}
return false
Expand All @@ -87,7 +84,7 @@ function validateQualifierKey(key, throws) {
function validateRequired(name, value, throws) {
if (isNullishOrEmptyString(value)) {
if (throws) {
throw new Error(`Invalid purl: "${name}" is a required field.`)
throw new PurlError(`"${name}" is a required component`)
}
return false
}
Expand All @@ -97,7 +94,7 @@ function validateRequired(name, value, throws) {
function validateRequiredByType(type, name, value, throws) {
if (isNullishOrEmptyString(value)) {
if (throws) {
throw new Error(`Invalid purl: ${type} requires a "${name}" field.`)
throw new PurlError(`${type} requires a "${name}" component`)
}
return false
}
Expand All @@ -109,8 +106,8 @@ function validateStartsWithoutNumber(name, value, throws) {
const code = value.charCodeAt(0)
if (code >= 48 /*'0'*/ && code <= 57 /*'9'*/) {
if (throws) {
throw new Error(
`Invalid purl: ${name} "${value}" cannot start with a number.`
throw new PurlError(
`${name} "${value}" cannot start with a number`
)
}
return false
Expand All @@ -124,7 +121,7 @@ function validateStrings(name, value, throws) {
return true
}
if (throws) {
throw new Error(`Invalid purl: "'${name}" argument must be a string.`)
throw new PurlError(`"'${name}" must be a string`)
}
return false
}
Expand Down Expand Up @@ -160,8 +157,8 @@ function validateType(type, throws) {
)
) {
if (throws) {
throw new Error(
`Invalid purl: type "${type}" contains an illegal character.`
throw new PurlError(
`type "${type}" contains an illegal character`
)
}
return false
Expand Down
Loading
Loading