Skip to content

Commit

Permalink
fix: validate header names and values (#69)
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito authored Sep 13, 2023
1 parent e970f59 commit ad5ead7
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 32 deletions.
82 changes: 70 additions & 12 deletions src/Headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,41 @@ describe('.entries()', () => {
})
})

describe('.has()', () => {
it('throws a TypeEror given an invalid header name', () => {
const headers = new Headers()
expect(() =>
headers.has(
// @ts-expect-error
123
)
).toThrow(new TypeError('Invalid header name "123"'))
})

it('returns true given an existing header name', () => {
const headers = new Headers({ accept: '*/*' })
expect(headers.has('accept')).toBe(true)
expect(headers.has('AcCePt')).toBe(true)
})

it('returns false given a non-existing header name', () => {
const headers = new Headers({ accept: '*/*' })
expect(headers.has('content-type')).toBe(false)
expect(headers.has('CoNtEnT-TyPe')).toBe(false)
})
})

describe('.get()', () => {
it('throws a TypeEror given an invalid header name', () => {
const headers = new Headers()
expect(() =>
headers.get(
// @ts-expect-error
123
)
).toThrow(new TypeError('Invalid header name "123"'))
})

it('returns the value of the existing header name', () => {
const headers = new Headers({ 'Content-Type': 'text/plain' })
expect(headers.get('Content-Type')).toEqual('text/plain')
Expand Down Expand Up @@ -282,6 +316,31 @@ describe('.raw()', () => {
})

describe('.set()', () => {
it('returns if given an invalid header name', () => {
const headers = new Headers()
expect(
headers.set(
// @ts-expect-error
123,
'value'
)
).toBeUndefined()
expect(Object.fromEntries(headers.entries())).toEqual({})
})

it('returns if given an invalid header value', () => {
const headers = new Headers()
expect(
headers.set(
'foo',
// @ts-expect-error
123
)
).toBeUndefined()
expect(headers.set('foo', ' value ')).toBeUndefined()
expect(Object.fromEntries(headers.entries())).toEqual({})
})

it('sets a new header', () => {
const headers = new Headers({ accept: '*/*' })

Expand Down Expand Up @@ -325,21 +384,20 @@ describe('.append()', () => {
})
})

describe('.has()', () => {
it('returns true given an existing header name', () => {
const headers = new Headers({ accept: '*/*' })
expect(headers.has('accept')).toBe(true)
expect(headers.has('AcCePt')).toBe(true)
})

it('returns false given a non-existing header name', () => {
describe('.delete()', () => {
it('returns if given an invalid header name', () => {
const headers = new Headers({ accept: '*/*' })
expect(headers.has('content-type')).toBe(false)
expect(headers.has('CoNtEnT-TyPe')).toBe(false)
expect(
headers.delete(
// @ts-expect-error
123
)
).toBeUndefined()
expect(Object.fromEntries(headers.entries())).toEqual({
accept: '*/*',
})
})
})

describe('.delete()', () => {
it('deletes the existing header', () => {
const headers = new Headers({ accept: '*/*' })

Expand Down
47 changes: 37 additions & 10 deletions src/Headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { splitCookiesString } from 'set-cookie-parser'
import { HeadersList, HeadersObject } from './glossary'
import { normalizeHeaderName } from './utils/normalizeHeaderName'
import { normalizeHeaderValue } from './utils/normalizeHeaderValue'
import { isValidHeaderName } from './utils/isValidHeaderName'
import { isValidHeaderValue } from './utils/isValidHeaderValue'

const NORMALIZED_HEADERS: unique symbol = Symbol('normalizedHeaders')
const RAW_HEADER_NAMES: unique symbol = Symbol('rawHeaderNames')
Expand Down Expand Up @@ -78,30 +80,58 @@ export default class HeadersPolyfill {
}
}

/**
* Returns a boolean stating whether a `Headers` object contains a certain header.
*/
has(name: string): boolean {
if (!isValidHeaderName(name)) {
throw new TypeError(`Invalid header name "${name}"`)
}

return this[NORMALIZED_HEADERS].hasOwnProperty(normalizeHeaderName(name))
}

/**
* Returns a `ByteString` sequence of all the values of a header with a given name.
*/
get(name: string): string | null {
if (!isValidHeaderName(name)) {
throw TypeError(`Invalid header name "${name}"`)
}

return this[NORMALIZED_HEADERS][normalizeHeaderName(name)] ?? null
}

/**
* Sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist.
*/
set(name: string, value: string): void {
if (!isValidHeaderName(name) || !isValidHeaderValue(value)) {
return
}

const normalizedName = normalizeHeaderName(name)
this[NORMALIZED_HEADERS][normalizedName] = normalizeHeaderValue(value)
const normalizedValue = normalizeHeaderValue(value)

this[NORMALIZED_HEADERS][normalizedName] =
normalizeHeaderValue(normalizedValue)
this[RAW_HEADER_NAMES].set(normalizedName, name)
}

/**
* Appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist.
*/
append(name: string, value: string): void {
if (!isValidHeaderName(name) || !isValidHeaderValue(value)) {
return
}

const normalizedName = normalizeHeaderName(name)
const normalizedValue = normalizeHeaderValue(value)

let resolvedValue = this.has(normalizedName)
? `${this.get(normalizedName)}, ${value}`
: value
? `${this.get(normalizedName)}, ${normalizedValue}`
: normalizedValue

this.set(name, resolvedValue)
}
Expand All @@ -110,6 +140,10 @@ export default class HeadersPolyfill {
* Deletes a header from the `Headers` object.
*/
delete(name: string): void {
if (!isValidHeaderName(name)) {
return
}

if (!this.has(name)) {
return
}
Expand Down Expand Up @@ -139,13 +173,6 @@ export default class HeadersPolyfill {
return rawHeaders
}

/**
* Returns a boolean stating whether a `Headers` object contains a certain header.
*/
has(name: string): boolean {
return this[NORMALIZED_HEADERS].hasOwnProperty(normalizeHeaderName(name))
}

/**
* Traverses the `Headers` object,
* calling the given callback for each header.
Expand Down
47 changes: 47 additions & 0 deletions src/utils/isValidHeaderName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Validate the given header name.
* @see https://fetch.spec.whatwg.org/#header-name
*/
export function isValidHeaderName(value: unknown) {
if (typeof value !== 'string') {
return false
}

if (value.length === 0) {
return false
}

for (let i = 0; i < value.length; i++) {
const character = value.charCodeAt(i)

if (character > 0x7f || !isToken(character)) {
return false
}
}

return true
}

function isToken(value: string | number): boolean {
return ![
0x7f,
0x20,
'(',
')',
'<',
'>',
'@',
',',
';',
':',
'\\',
'"',
'/',
'[',
']',
'?',
'=',
'{',
'}',
].includes(value)
}
29 changes: 29 additions & 0 deletions src/utils/isValidHeaderValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Validate the given header value.
* @see https://fetch.spec.whatwg.org/#header-value
*/
export function isValidHeaderValue(value: unknown): boolean {
if (typeof value !== 'string') {
return false
}

if (value.trim() !== value) {
return false
}

for (let i = 0; i < value.length; i++) {
const character = value.charCodeAt(i)

if (
// NUL.
character === 0x00 ||
// HTTP newline bytes.
character === 0x0a ||
character === 0x0d
) {
return false
}
}

return true
}
6 changes: 1 addition & 5 deletions src/utils/normalizeHeaderName.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
const HEADERS_INVALID_CHARACTERS = /[^a-z0-9\-#$%&'*+.^_`|~]/i

export function normalizeHeaderName(name: string): string {
if (typeof name !== 'string') {
name = String(name)
}

if (HEADERS_INVALID_CHARACTERS.test(name) || name.trim() === '') {
throw new TypeError('Invalid character in header field name')
}

return name.toLowerCase()
return name.trim().toLowerCase()
}
23 changes: 18 additions & 5 deletions src/utils/normalizeHeaderValue.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
export function normalizeHeaderValue(value: string): string {
if (typeof value !== 'string') {
value = String(value)
}
const charCodesToRemove = [
String.fromCharCode(0x0a),
String.fromCharCode(0x0d),
String.fromCharCode(0x09),
String.fromCharCode(0x20),
]

const HEADER_VALUE_REMOVE_REGEXP = new RegExp(
`(^[${charCodesToRemove.join('')}]|$[${charCodesToRemove.join('')}])`,
'g'
)

return value
/**
* Normalize the given header value.
* @see https://fetch.spec.whatwg.org/#concept-header-value-normalize
*/
export function normalizeHeaderValue(value: string): string {
const nextValue = value.replace(HEADER_VALUE_REMOVE_REGEXP, '')
return nextValue
}

0 comments on commit ad5ead7

Please sign in to comment.