Skip to content

Commit

Permalink
Merge branch 'main' into next
Browse files Browse the repository at this point in the history
  • Loading branch information
yusukebe committed Sep 9, 2023
2 parents f2bdfd8 + d6ec48e commit 0f6f403
Show file tree
Hide file tree
Showing 5 changed files with 211 additions and 128 deletions.
4 changes: 2 additions & 2 deletions deno_dist/helper/cookie/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface GetCookie {
}

interface GetSignedCookie {
(c: Context, secret: string, key: string): Promise<string | undefined | false>
(c: Context, secret: string | BufferSource, key: string): Promise<string | undefined | false>
(c: Context, secret: string): Promise<SignedCookie>
}

Expand Down Expand Up @@ -47,7 +47,7 @@ export const setSignedCookie = async (
c: Context,
name: string,
value: string,
secret: string,
secret: string | BufferSource,
opt?: CookieOptions
): Promise<void> => {
const cookie = await serializeSigned(name, value, secret, opt)
Expand Down
126 changes: 66 additions & 60 deletions deno_dist/utils/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,80 +13,86 @@ export type CookieOptions = {
sameSite?: 'Strict' | 'Lax' | 'None'
}

const makeSignature = async (value: string, secret: string): Promise<string> => {
const algorithm = { name: 'HMAC', hash: 'SHA-256' }
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), algorithm, false, [
'sign',
'verify',
])
const signature = await crypto.subtle.sign(algorithm.name, key, encoder.encode(value))
const algorithm = { name: 'HMAC', hash: 'SHA-256' }

const getCryptoKey = async (secret: string | BufferSource): Promise<CryptoKey> => {
const secretBuf = typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
return await crypto.subtle.importKey('raw', secretBuf, algorithm, false, ['sign', 'verify'])
}

const makeSignature = async (value: string, secret: string | BufferSource): Promise<string> => {
const key = await getCryptoKey(secret)
const signature = await crypto.subtle.sign(algorithm.name, key, new TextEncoder().encode(value))
// the returned base64 encoded signature will always be 44 characters long and end with one or two equal signs
return btoa(String.fromCharCode(...new Uint8Array(signature)))
}

const _parseCookiePairs = (cookie: string, name?: string): string[][] => {
const pairs = cookie.split(/;\s*/g)
const cookiePairs = pairs.map((pairStr: string) => pairStr.split(/\s*=\s*([^\s]+)/))
if (!name) return cookiePairs
return cookiePairs.filter((pair) => pair[0] === name)
const verifySignature = async (
base64Signature: string,
value: string,
secret: CryptoKey
): Promise<boolean> => {
try {
const signatureBinStr = atob(base64Signature)
const signature = new Uint8Array(signatureBinStr.length)
for (let i = 0; i < signatureBinStr.length; i++) signature[i] = signatureBinStr.charCodeAt(i)
return await crypto.subtle.verify(algorithm, secret, signature, new TextEncoder().encode(value))
} catch (e) {
return false
}
}

// all alphanumeric chars and all of _!#$%&'*.^`|~+-
// (see: https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1)
const validCookieNameRegEx = /^[\w!#$%&'*.^`|~+-]+$/

// all ASCII chars 32-126 except 34, 59, and 92 (i.e. space to tilde but not double quote, semicolon, or backslash)
// (see: https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1)
//
// note: the spec also prohibits comma and space, but we allow both since they are very common in the real world
// (see: https://github.com/golang/go/issues/7243)
const validCookieValueRegEx = /^[ !#-:<-[\]-~]*$/

export const parse = (cookie: string, name?: string): Cookie => {
const parsedCookie: Cookie = {}
const unsignedCookies = _parseCookiePairs(cookie, name).filter((pair) => {
// ignore signed cookies, assuming they always have that commonly accepted format
const valueSplit = pair[1].split('.')
const signature = valueSplit[1] ? decodeURIComponent_(valueSplit[1]) : undefined
if (
valueSplit.length === 2 &&
signature &&
signature.length === 44 &&
signature.endsWith('=')
) {
return false
}
return true
})
for (let [key, value] of unsignedCookies) {
value = decodeURIComponent_(value)
parsedCookie[key] = value
}
return parsedCookie
const pairs = cookie.trim().split(';')
return pairs.reduce((parsedCookie, pairStr) => {
pairStr = pairStr.trim()
const valueStartPos = pairStr.indexOf('=')
if (valueStartPos === -1) return parsedCookie

const cookieName = pairStr.substring(0, valueStartPos).trim()
if ((name && name !== cookieName) || !validCookieNameRegEx.test(cookieName)) return parsedCookie

let cookieValue = pairStr.substring(valueStartPos + 1).trim()
if (cookieValue.startsWith('"') && cookieValue.endsWith('"'))
cookieValue = cookieValue.slice(1, -1)
if (validCookieValueRegEx.test(cookieValue))
parsedCookie[cookieName] = decodeURIComponent_(cookieValue)

return parsedCookie
}, {} as Cookie)
}

export const parseSigned = async (
cookie: string,
secret: string,
secret: string | BufferSource,
name?: string
): Promise<SignedCookie> => {
const parsedCookie: SignedCookie = {}
const signedCookies = _parseCookiePairs(cookie, name).filter((pair) => {
// ignore signed cookies, assuming they always have that commonly accepted format
const valueSplit = pair[1].split('.')
const signature = valueSplit[1] ? decodeURIComponent_(valueSplit[1]) : undefined
if (
valueSplit.length !== 2 ||
!signature ||
signature.length !== 44 ||
!signature.endsWith('=')
) {
console.log('VALUE SPLIT', valueSplit)
return false
}
return true
})
for (let [key, value] of signedCookies) {
value = decodeURIComponent_(value)
const signedPair = value.split('.')
const signatureToCompare = await makeSignature(signedPair[0], secret)
if (signedPair[1] !== signatureToCompare) {
// cookie will be undefined when using getCookie
parsedCookie[key] = false
continue
}
parsedCookie[key] = signedPair[0]
const secretKey = await getCryptoKey(secret)

for (const [key, value] of Object.entries(parse(cookie, name))) {
const signatureStartPos = value.lastIndexOf('.')
if (signatureStartPos < 1) continue

const signedValue = value.substring(0, signatureStartPos)
const signature = value.substring(signatureStartPos + 1)
if (signature.length !== 44 || !signature.endsWith('=')) continue

const isVerified = await verifySignature(signature, signedValue, secretKey)
parsedCookie[key] = isVerified ? signedValue : false
}

return parsedCookie
}

Expand Down Expand Up @@ -132,7 +138,7 @@ export const serialize = (name: string, value: string, opt: CookieOptions = {}):
export const serializeSigned = async (
name: string,
value: string,
secret: string,
secret: string | BufferSource,
opt: CookieOptions = {}
): Promise<string> => {
const signature = await makeSignature(value, secret)
Expand Down
4 changes: 2 additions & 2 deletions src/helper/cookie/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface GetCookie {
}

interface GetSignedCookie {
(c: Context, secret: string, key: string): Promise<string | undefined | false>
(c: Context, secret: string | BufferSource, key: string): Promise<string | undefined | false>
(c: Context, secret: string): Promise<SignedCookie>
}

Expand Down Expand Up @@ -47,7 +47,7 @@ export const setSignedCookie = async (
c: Context,
name: string,
value: string,
secret: string,
secret: string | BufferSource,
opt?: CookieOptions
): Promise<void> => {
const cookie = await serializeSigned(name, value, secret, opt)
Expand Down
79 changes: 75 additions & 4 deletions src/utils/cookie.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,62 @@ describe('Parse cookie', () => {
expect(cookie['tasty_cookie']).toBe('strawberry')
})

it('Should parse quoted cookie values', () => {
const cookieString =
'yummy_cookie="choco"; tasty_cookie = " strawberry " ; best_cookie="%20sugar%20";'
const cookie: Cookie = parse(cookieString)
expect(cookie['yummy_cookie']).toBe('choco')
expect(cookie['tasty_cookie']).toBe(' strawberry ')
expect(cookie['best_cookie']).toBe(' sugar ')
})

it('Should parse empty cookies', () => {
const cookie: Cookie = parse('')
expect(Object.keys(cookie).length).toBe(0)
})

it('Should parse one cookie specified by name', () => {
const cookieString = 'yummy_cookie=choco; tasty_cookie = strawberry '
const cookie: Cookie = parse(cookieString, 'yummy_cookie')
expect(cookie['yummy_cookie']).toBe('choco')
expect(cookie['tasty_cookie']).toBeUndefined()
})

it('Should parse cookies but ignore signed cookies', () => {
it('Should parse cookies with no value', () => {
const cookieString = 'yummy_cookie=; tasty_cookie = ; best_cookie= ; last_cookie=""'
const cookie: Cookie = parse(cookieString)
expect(cookie['yummy_cookie']).toBe('')
expect(cookie['tasty_cookie']).toBe('')
expect(cookie['best_cookie']).toBe('')
expect(cookie['last_cookie']).toBe('')
})

it('Should parse cookies but not process signed cookies', () => {
// also contains another cookie with a '.' in its value to test it is not misinterpreted as signed cookie
const cookieString =
'yummy_cookie=choco; tasty_cookie = strawberry.I9qAeGQOvWjCEJgRPmrw90JjYpnnX2C9zoOiGSxh1Ig%3D; great_cookie=rating3.5'
'yummy_cookie=choco; tasty_cookie = strawberry.I9qAeGQOvWjCEJgRPmrw90JjYpnnX2C9zoOiGSxh1Ig%3D; great_cookie=rating3.5; best_cookie=sugar.valueShapedLikeASignatureButIsNotASignature%3D'
const cookie: Cookie = parse(cookieString)
expect(cookie['yummy_cookie']).toBe('choco')
expect(cookie['tasty_cookie']).toBeUndefined()
expect(cookie['tasty_cookie']).toBe('strawberry.I9qAeGQOvWjCEJgRPmrw90JjYpnnX2C9zoOiGSxh1Ig=')
expect(cookie['great_cookie']).toBe('rating3.5')
expect(cookie['best_cookie']).toBe('sugar.valueShapedLikeASignatureButIsNotASignature=')
})

it('Should ignore invalid cookie names', () => {
const cookieString = 'yummy_cookie=choco; tasty cookie=strawberry; best_cookie\\=sugar; =ng'
const cookie: Cookie = parse(cookieString)
expect(cookie['yummy_cookie']).toBe('choco')
expect(cookie['tasty cookie']).toBeUndefined()
expect(cookie['best_cookie\\']).toBeUndefined()
expect(cookie['']).toBeUndefined()
})

it('Should ignore invalid cookie values', () => {
const cookieString = 'yummy_cookie=choco\\nchip; tasty_cookie=strawberry; best_cookie="sugar'
const cookie: Cookie = parse(cookieString)
expect(cookie['yummy_cookie']).toBeUndefined()
expect(cookie['tasty_cookie']).toBe('strawberry')
expect(cookie['best_cookie\\']).toBeUndefined()
})

it('Should parse signed cookies', async () => {
Expand All @@ -35,6 +76,24 @@ describe('Parse cookie', () => {
expect(cookie['tasty_cookie']).toBe('strawberry')
})

it('Should parse signed cookies with binary secret', async () => {
const secret = new Uint8Array([
172, 142, 204, 63, 210, 136, 58, 143, 25, 18, 159, 16, 161, 34, 94,
])
const cookieString =
'yummy_cookie=choco.8Km4IwZETZdwiOfrT7KgYjKXwiO98XIkms0tOtRa2TA%3D; tasty_cookie = strawberry.TbV33P%2Bi1K0JTxMzNYq7FV9fB4s2VlQcBCBFDxTrUSg%3D'
const cookie: SignedCookie = await parseSigned(cookieString, secret)
expect(cookie['yummy_cookie']).toBe('choco')
expect(cookie['tasty_cookie']).toBe('strawberry')
})

it('Should parse signed cookies containing the signature separator', async () => {
const secret = 'secret ingredient'
const cookieString = 'yummy_cookie=choco.chip.2%2FJA0c68Y3zm0DvSvHyR6IRysDWmHW0LfoaC0AkyOpw%3D'
const cookie: SignedCookie = await parseSigned(cookieString, secret)
expect(cookie['yummy_cookie']).toBe('choco.chip')
})

it('Should parse signed cookies and return "false" for wrong signature', async () => {
const secret = 'secret ingredient'
// tasty_cookie has invalid signature
Expand All @@ -45,6 +104,18 @@ describe('Parse cookie', () => {
expect(cookie['tasty_cookie']).toBe(false)
})

it('Should parse signed cookies and return "false" for corrupt signature', async () => {
const secret = 'secret ingredient'
// yummy_cookie has corrupt signature (i.e. invalid base64 encoding)
// best_cookie has a shape that matches the signature format but isn't actually a signature
const cookieString =
'yummy_cookie=choco.?dFR2rBpS1GsHfGlUiYyMIdqxqwuEgplyQIgTJgpGWY%3D; tasty_cookie = strawberry.I9qAeGQOvWjCEJgRPmrw90JjYpnnX2C9zoOiGSxh1Ig%3D; best_cookie=sugar.valueShapedLikeASignatureButIsNotASignature%3D'
const cookie: SignedCookie = await parseSigned(cookieString, secret)
expect(cookie['yummy_cookie']).toBe(false)
expect(cookie['tasty_cookie']).toBe('strawberry')
expect(cookie['best_cookie']).toBe(false)
})

it('Should parse one signed cookie specified by name', async () => {
const secret = 'secret ingredient'
const cookieString =
Expand Down Expand Up @@ -105,7 +176,7 @@ describe('Set cookie', () => {
)
})

it('Should serialize singed cookie with all options', async () => {
it('Should serialize signed cookie with all options', async () => {
const secret = 'secret chocolate chips'
const serialized = await serializeSigned('great_cookie', 'banana', secret, {
path: '/',
Expand Down
Loading

0 comments on commit 0f6f403

Please sign in to comment.