From 17d84121506cbcbca91d54a59c5d1541148dd632 Mon Sep 17 00:00:00 2001 From: Khafra <42794878+KhafraDev@users.noreply.github.com> Date: Thu, 5 Jan 2023 13:43:15 -0500 Subject: [PATCH 1/4] feat: add cookie parsing ability --- index.js | 7 + lib/cookies/constants.js | 12 + lib/cookies/index.js | 184 ++++++++++++ lib/cookies/parse.js | 317 +++++++++++++++++++++ lib/cookies/util.js | 266 ++++++++++++++++++ lib/fetch/headers.js | 17 ++ package.json | 3 +- test/cookie/cookies.js | 594 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 1399 insertions(+), 1 deletion(-) create mode 100644 lib/cookies/constants.js create mode 100644 lib/cookies/index.js create mode 100644 lib/cookies/parse.js create mode 100644 lib/cookies/util.js create mode 100644 test/cookie/cookies.js diff --git a/index.js b/index.js index 2ed0d1e0d62..841c52627a9 100644 --- a/index.js +++ b/index.js @@ -123,6 +123,13 @@ if (nodeMajor >= 18) { const { WebSocket } = require('./lib/websocket/websocket') module.exports.WebSocket = WebSocket + + const { deleteCookie, getCookies, getSetCookies, setCookie } = require('./lib/cookies') + + module.exports.deleteCookie = deleteCookie + module.exports.getCookies = getCookies + module.exports.getSetCookies = getSetCookies + module.exports.setCookie = setCookie } module.exports.request = makeDispatcher(api.request) diff --git a/lib/cookies/constants.js b/lib/cookies/constants.js new file mode 100644 index 00000000000..85f1fec0e93 --- /dev/null +++ b/lib/cookies/constants.js @@ -0,0 +1,12 @@ +'use strict' + +// https://wicg.github.io/cookie-store/#cookie-maximum-attribute-value-size +const maxAttributeValueSize = 1024 + +// https://wicg.github.io/cookie-store/#cookie-maximum-name-value-pair-size +const maxNameValuePairSize = 4096 + +module.exports = { + maxAttributeValueSize, + maxNameValuePairSize +} diff --git a/lib/cookies/index.js b/lib/cookies/index.js new file mode 100644 index 00000000000..8856332a58f --- /dev/null +++ b/lib/cookies/index.js @@ -0,0 +1,184 @@ +'use strict' + +const { parseSetCookie } = require('./parse') +const { stringify } = require('./util') +const { webidl } = require('../fetch/webidl') +const { Headers } = require('../fetch/headers') +const { kHeadersList } = require('../core/symbols') + +/** + * @typedef {Object} Cookie + * @property {string} name + * @property {string} value + * @property {Date|number|undefined} expires + * @property {number|undefined} maxAge + * @property {string|undefined} domain + * @property {string|undefined} path + * @property {boolean|undefined} secure + * @property {boolean|undefined} httpOnly + * @property {'Strict'|'Lax'|'None'} sameSite + * @property {string[]} unparsed + */ + +/** + * @param {Headers} headers + * @returns {Record} + */ +function getCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, { header: 'getCookies' }) + + webidl.brandCheck(headers, Headers) + + const cookie = headers[kHeadersList].get('cookie') + const out = {} + + if (!cookie) { + return out + } + + for (const piece of cookie.split(';')) { + const [name, ...value] = piece.split('=') + + out[name.trim()] = value.join('=') + } + + return out +} + +/** + * @param {Headers} headers + * @param {string} name + * @param {{ path?: string, domain?: string }|undefined} attributes + * @returns {void} + */ +function deleteCookie (headers, name, attributes) { + webidl.argumentLengthCheck(arguments, 2, { header: 'deleteCookie' }) + + webidl.brandCheck(headers, Headers) + + name = webidl.converters.DOMString(name) + attributes = webidl.converters.DeleteCookieAttributes(attributes) + + // Matches behavior of + // https://github.com/denoland/deno_std/blob/63827b16330b82489a04614027c33b7904e08be5/http/cookie.ts#L278 + setCookie(headers, { + name, + value: '', + expires: new Date(0), + ...attributes + }) +} + +/** + * @param {Headers} headers + * @returns {Cookie[]} + */ +function getSetCookies (headers) { + webidl.argumentLengthCheck(arguments, 1, { header: 'getSetCookies' }) + + webidl.brandCheck(headers, Headers) + + const cookies = headers[kHeadersList].cookies + + if (!cookies) { + return [] + } + + return cookies.map((pair) => parseSetCookie(pair[1])) +} + +/** + * @param {Headers} headers + * @param {Cookie} cookie + * @returns {void} + */ +function setCookie (headers, cookie) { + webidl.argumentLengthCheck(arguments, 2, { header: 'setCookie' }) + + webidl.brandCheck(headers, Headers) + + cookie = webidl.converters.Cookie(cookie) + + const str = stringify(cookie) + + if (str) { + headers.append('Set-Cookie', stringify(cookie)) + } +} + +webidl.converters.DeleteCookieAttributes = webidl.dictionaryConverter([ + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: null + } +]) + +webidl.converters.Cookie = webidl.dictionaryConverter([ + { + converter: webidl.converters.DOMString, + key: 'name' + }, + { + converter: webidl.converters.DOMString, + key: 'value' + }, + { + converter: webidl.nullableConverter((value) => { + if (typeof value === 'number') { + return webidl.converters['unsigned long long'](value) + } + + return new Date(value) + }), + key: 'expires', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters['long long']), + key: 'maxAge', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'domain', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.DOMString), + key: 'path', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'secure', + defaultValue: null + }, + { + converter: webidl.nullableConverter(webidl.converters.boolean), + key: 'httpOnly', + defaultValue: null + }, + { + converter: webidl.converters.USVString, + key: 'sameSite', + allowedValues: ['Strict', 'Lax', 'None'] + }, + { + converter: webidl.sequenceConverter(webidl.converters.DOMString), + key: 'unparsed', + defaultValue: [] + } +]) + +module.exports = { + getCookies, + deleteCookie, + getSetCookies, + setCookie +} diff --git a/lib/cookies/parse.js b/lib/cookies/parse.js new file mode 100644 index 00000000000..6a1e37be6b7 --- /dev/null +++ b/lib/cookies/parse.js @@ -0,0 +1,317 @@ +'use strict' + +const { maxNameValuePairSize, maxAttributeValueSize } = require('./constants') +const { isCTLExcludingHtab } = require('./util') +const { collectASequenceOfCodePoints } = require('../fetch/dataURL') +const assert = require('assert') + +/** + * @description Parses the field-value attributes of a set-cookie header string. + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} header + * @returns if the header is invalid, null will be returned + */ +function parseSetCookie (header) { + // 1. If the set-cookie-string contains a %x00-08 / %x0A-1F / %x7F + // character (CTL characters excluding HTAB): Abort these steps and + // ignore the set-cookie-string entirely. + if (isCTLExcludingHtab(header)) { + return null + } + + let nameValuePair = '' + let unparsedAttributes = '' + let name = '' + let value = '' + + // 2. If the set-cookie-string contains a %x3B (";") character: + if (header.includes(';')) { + // 1. The name-value-pair string consists of the characters up to, + // but not including, the first %x3B (";"), and the unparsed- + // attributes consist of the remainder of the set-cookie-string + // (including the %x3B (";") in question). + const position = { position: 0 } + + nameValuePair = collectASequenceOfCodePoints((char) => char !== ';', header, position) + unparsedAttributes = header.slice(position.position) + } else { + // Otherwise: + + // 1. The name-value-pair string consists of all the characters + // contained in the set-cookie-string, and the unparsed- + // attributes is the empty string. + nameValuePair = header + } + + // 3. If the name-value-pair string lacks a %x3D ("=") character, then + // the name string is empty, and the value string is the value of + // name-value-pair. + if (!nameValuePair.includes('=')) { + value = nameValuePair + } else { + // Otherwise, the name string consists of the characters up to, but + // not including, the first %x3D ("=") character, and the (possibly + // empty) value string consists of the characters after the first + // %x3D ("=") character. + const position = { position: 0 } + name = collectASequenceOfCodePoints( + (char) => char !== '=', + nameValuePair, + position + ) + value = nameValuePair.slice(position.position + 1) + } + + // 4. Remove any leading or trailing WSP characters from the name + // string and the value string. + name = name.trim() + value = value.trim() + + // 5. If the sum of the lengths of the name string and the value string + // is more than 4096 octets, abort these steps and ignore the set- + // cookie-string entirely. + if (name.length + value.length > maxNameValuePairSize) { + return null + } + + // 6. The cookie-name is the name string, and the cookie-value is the + // value string. + return { + name, value, ...parseUnparsedAttributes(unparsedAttributes) + } +} + +/** + * Parses the remaining attributes of a set-cookie header + * @see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4 + * @param {string} unparsedAttributes + * @param {[Object.]={}} cookieAttributeList + */ +function parseUnparsedAttributes (unparsedAttributes, cookieAttributeList = {}) { + // 1. If the unparsed-attributes string is empty, skip the rest of + // these steps. + if (unparsedAttributes.length === 0) { + return cookieAttributeList + } + + // 2. Discard the first character of the unparsed-attributes (which + // will be a %x3B (";") character). + assert(unparsedAttributes[0] === ';') + unparsedAttributes = unparsedAttributes.slice(1) + + let cookieAv = '' + + // 3. If the remaining unparsed-attributes contains a %x3B (";") + // character: + if (unparsedAttributes.includes(';')) { + // 1. Consume the characters of the unparsed-attributes up to, but + // not including, the first %x3B (";") character. + cookieAv = collectASequenceOfCodePoints( + (char) => char !== ';', + unparsedAttributes, + { position: 0 } + ) + unparsedAttributes = unparsedAttributes.slice(cookieAv.length) + } else { + // Otherwise: + + // 1. Consume the remainder of the unparsed-attributes. + cookieAv = unparsedAttributes + unparsedAttributes = '' + } + + // Let the cookie-av string be the characters consumed in this step. + + let attributeName = '' + let attributeValue = '' + + // 4. If the cookie-av string contains a %x3D ("=") character: + if (cookieAv.includes('=')) { + // 1. The (possibly empty) attribute-name string consists of the + // characters up to, but not including, the first %x3D ("=") + // character, and the (possibly empty) attribute-value string + // consists of the characters after the first %x3D ("=") + // character. + const position = { position: 0 } + + attributeName = collectASequenceOfCodePoints( + (char) => char !== '=', + cookieAv, + position + ) + attributeValue = cookieAv.slice(position.position + 1) + } else { + // Otherwise: + + // 1. The attribute-name string consists of the entire cookie-av + // string, and the attribute-value string is empty. + attributeName = cookieAv + } + + // 5. Remove any leading or trailing WSP characters from the attribute- + // name string and the attribute-value string. + attributeName = attributeName.trim() + attributeValue = attributeValue.trim() + + // 6. If the attribute-value is longer than 1024 octets, ignore the + // cookie-av string and return to Step 1 of this algorithm. + if (attributeValue.length > maxAttributeValueSize) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 7. Process the attribute-name and attribute-value according to the + // requirements in the following subsections. (Notice that + // attributes with unrecognized attribute-names are ignored.) + const attributeNameLowercase = attributeName.toLowerCase() + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.1 + // If the attribute-name case-insensitively matches the string + // "Expires", the user agent MUST process the cookie-av as follows. + if (attributeNameLowercase === 'expires') { + // 1. Let the expiry-time be the result of parsing the attribute-value + // as cookie-date (see Section 5.1.1). + const expiryTime = new Date(attributeValue) + + // 2. If the attribute-value failed to parse as a cookie date, ignore + // the cookie-av. + + cookieAttributeList.expires = expiryTime + } else if (attributeNameLowercase === 'max-age') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.2 + // If the attribute-name case-insensitively matches the string "Max- + // Age", the user agent MUST process the cookie-av as follows. + + // 1. If the first character of the attribute-value is not a DIGIT or a + // "-" character, ignore the cookie-av. + const charCode = attributeValue.charCodeAt(0) + + if ((charCode < 48 || charCode > 57) && attributeValue[0] !== '-') { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 2. If the remainder of attribute-value contains a non-DIGIT + // character, ignore the cookie-av. + if (!/^\d+$/.test(attributeValue)) { + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) + } + + // 3. Let delta-seconds be the attribute-value converted to an integer. + const deltaSeconds = Number(attributeValue) + + // 4. Let cookie-age-limit be the maximum age of the cookie (which + // SHOULD be 400 days or less, see Section 4.1.2.2). + + // 5. Set delta-seconds to the smaller of its present value and cookie- + // age-limit. + // deltaSeconds = Math.min(deltaSeconds * 1000, maxExpiresMs) + + // 6. If delta-seconds is less than or equal to zero (0), let expiry- + // time be the earliest representable date and time. Otherwise, let + // the expiry-time be the current date and time plus delta-seconds + // seconds. + // const expiryTime = deltaSeconds <= 0 ? Date.now() : Date.now() + deltaSeconds + + // 7. Append an attribute to the cookie-attribute-list with an + // attribute-name of Max-Age and an attribute-value of expiry-time. + cookieAttributeList.maxAge = deltaSeconds + } else if (attributeNameLowercase === 'domain') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.3 + // If the attribute-name case-insensitively matches the string "Domain", + // the user agent MUST process the cookie-av as follows. + + // 1. Let cookie-domain be the attribute-value. + let cookieDomain = attributeValue + + // 2. If cookie-domain starts with %x2E ("."), let cookie-domain be + // cookie-domain without its leading %x2E ("."). + if (cookieDomain[0] === '.') { + cookieDomain = cookieDomain.slice(1) + } + + // 3. Convert the cookie-domain to lower case. + cookieDomain = cookieDomain.toLowerCase() + + // 4. Append an attribute to the cookie-attribute-list with an + // attribute-name of Domain and an attribute-value of cookie-domain. + cookieAttributeList.domain = cookieDomain + } else if (attributeNameLowercase === 'path') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.4 + // If the attribute-name case-insensitively matches the string "Path", + // the user agent MUST process the cookie-av as follows. + + // 1. If the attribute-value is empty or if the first character of the + // attribute-value is not %x2F ("/"): + let cookiePath = '' + if (attributeValue.length === 0 || attributeValue[0] !== '/') { + // 1. Let cookie-path be the default-path. + cookiePath = '/' + } else { + // Otherwise: + + // 1. Let cookie-path be the attribute-value. + cookiePath = attributeValue + } + + // 2. Append an attribute to the cookie-attribute-list with an + // attribute-name of Path and an attribute-value of cookie-path. + cookieAttributeList.path = cookiePath + } else if (attributeNameLowercase === 'secure') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.5 + // If the attribute-name case-insensitively matches the string "Secure", + // the user agent MUST append an attribute to the cookie-attribute-list + // with an attribute-name of Secure and an empty attribute-value. + + cookieAttributeList.secure = true + } else if (attributeNameLowercase === 'httponly') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.6 + // If the attribute-name case-insensitively matches the string + // "HttpOnly", the user agent MUST append an attribute to the cookie- + // attribute-list with an attribute-name of HttpOnly and an empty + // attribute-value. + + cookieAttributeList.httpOnly = true + } else if (attributeNameLowercase === 'samesite') { + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#section-5.4.7 + // If the attribute-name case-insensitively matches the string + // "SameSite", the user agent MUST process the cookie-av as follows: + + // 1. Let enforcement be "Default". + let enforcement = 'Default' + + const attributeValueLowercase = attributeValue.toLowerCase() + // 2. If cookie-av's attribute-value is a case-insensitive match for + // "None", set enforcement to "None". + if (attributeValueLowercase.includes('none')) { + enforcement = 'None' + } + + // 3. If cookie-av's attribute-value is a case-insensitive match for + // "Strict", set enforcement to "Strict". + if (attributeValueLowercase.includes('strict')) { + enforcement = 'Strict' + } + + // 4. If cookie-av's attribute-value is a case-insensitive match for + // "Lax", set enforcement to "Lax". + if (attributeValueLowercase.includes('lax')) { + enforcement = 'Lax' + } + + // 5. Append an attribute to the cookie-attribute-list with an + // attribute-name of "SameSite" and an attribute-value of + // enforcement. + cookieAttributeList.sameSite = enforcement + } else { + cookieAttributeList.unparsed ??= [] + + cookieAttributeList.unparsed.push(`${attributeName}=${attributeValue}`) + } + + // 8. Return to Step 1 of this algorithm. + return parseUnparsedAttributes(unparsedAttributes, cookieAttributeList) +} + +module.exports = { + parseSetCookie, + parseUnparsedAttributes +} diff --git a/lib/cookies/util.js b/lib/cookies/util.js new file mode 100644 index 00000000000..d8c189fcd53 --- /dev/null +++ b/lib/cookies/util.js @@ -0,0 +1,266 @@ +'use strict' + +function isCTLExcludingHtab (value) { + if (value.length === 0) { + return false + } + + for (const char of value) { + const code = char.charCodeAt(0) + + if ( + (code >= 0x00 || code <= 0x08) || + (code >= 0x0A || code <= 0x1F) || + code === 0x7F + ) { + return false + } + } +} + +/** + CHAR = + token = 1* + separators = "(" | ")" | "<" | ">" | "@" + | "," | ";" | ":" | "\" | <"> + | "/" | "[" | "]" | "?" | "=" + | "{" | "}" | SP | HT + * @param {string} name + */ +function validateCookieName (name) { + for (const char of name) { + const code = char.charCodeAt(0) + + if ( + (code <= 0x20 || code > 0x7F) || + char === '(' || + char === ')' || + char === '>' || + char === '<' || + char === '@' || + char === ',' || + char === ';' || + char === ':' || + char === '\\' || + char === '"' || + char === '/' || + char === '[' || + char === ']' || + char === '?' || + char === '=' || + char === '{' || + char === '}' + ) { + throw new Error('Invalid cookie name') + } + } +} + +/** + cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) + cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E + ; US-ASCII characters excluding CTLs, + ; whitespace DQUOTE, comma, semicolon, + ; and backslash + * @param {string} value + */ +function validateCookieValue (value) { + for (const char of value) { + const code = char.charCodeAt(0) + + if ( + code < 0x21 || // exclude CTLs (0-31) + code === 0x22 || + code === 0x2C || + code === 0x3B || + code === 0x5C || + code > 0x7E // non-ascii + ) { + throw new Error('Invalid header value') + } + } +} + +/** + * path-value = + * @param {string} path + */ +function validateCookiePath (path) { + for (const char of path) { + const code = char.charCodeAt(0) + + if (code < 0x21 || char === ';') { + throw new Error('Invalid cookie path') + } + } +} + +/** + * I have no idea why these values aren't allowed to be honest, + * but Deno tests these. - Khafra + * @param {string} domain + */ +function validateCookieDomain (domain) { + if ( + domain.startsWith('-') || + domain.endsWith('.') || + domain.endsWith('-') + ) { + throw new Error('Invalid cookie domain') + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1 + * @param {number|Date} date + IMF-fixdate = day-name "," SP date1 SP time-of-day SP GMT + ; fixed length/zone/capitalization subset of the format + ; see Section 3.3 of [RFC5322] + + day-name = %x4D.6F.6E ; "Mon", case-sensitive + / %x54.75.65 ; "Tue", case-sensitive + / %x57.65.64 ; "Wed", case-sensitive + / %x54.68.75 ; "Thu", case-sensitive + / %x46.72.69 ; "Fri", case-sensitive + / %x53.61.74 ; "Sat", case-sensitive + / %x53.75.6E ; "Sun", case-sensitive + date1 = day SP month SP year + ; e.g., 02 Jun 1982 + + day = 2DIGIT + month = %x4A.61.6E ; "Jan", case-sensitive + / %x46.65.62 ; "Feb", case-sensitive + / %x4D.61.72 ; "Mar", case-sensitive + / %x41.70.72 ; "Apr", case-sensitive + / %x4D.61.79 ; "May", case-sensitive + / %x4A.75.6E ; "Jun", case-sensitive + / %x4A.75.6C ; "Jul", case-sensitive + / %x41.75.67 ; "Aug", case-sensitive + / %x53.65.70 ; "Sep", case-sensitive + / %x4F.63.74 ; "Oct", case-sensitive + / %x4E.6F.76 ; "Nov", case-sensitive + / %x44.65.63 ; "Dec", case-sensitive + year = 4DIGIT + + GMT = %x47.4D.54 ; "GMT", case-sensitive + + time-of-day = hour ":" minute ":" second + ; 00:00:00 - 23:59:60 (leap second) + + hour = 2DIGIT + minute = 2DIGIT + second = 2DIGIT + */ +function toIMFDate (date) { + if (typeof date === 'number') { + date = new Date(date) + } + + const days = [ + 'Sun', 'Mon', 'Tue', 'Wed', + 'Thu', 'Fri', 'Sat' + ] + + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ] + + const dayName = days[date.getUTCDay()] + const day = date.getUTCDate().toString().padStart(2, '0') + const month = months[date.getUTCMonth()] + const year = date.getUTCFullYear() + const hour = date.getUTCHours().toString().padStart(2, '0') + const minute = date.getUTCMinutes().toString().padStart(2, '0') + const second = date.getUTCSeconds().toString().padStart(2, '0') + + return `${dayName}, ${day} ${month} ${year} ${hour}:${minute}:${second} GMT` +} + +/** + max-age-av = "Max-Age=" non-zero-digit *DIGIT + ; In practice, both expires-av and max-age-av + ; are limited to dates representable by the + ; user agent. + * @param {number} maxAge + */ +function validateCookieMaxAge (maxAge) { + if (maxAge < 0) { + throw new Error('Invalid cookie max-age') + } +} + +/** + * @see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.1 + * @param {import('./index').Cookie} cookie + */ +function stringify (cookie) { + if (cookie.name.length === 0) { + return null + } + + validateCookieName(cookie.name) + validateCookieValue(cookie.value) + + const out = [`${cookie.name}=${cookie.value}`] + + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.1 + // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-cookie-prefixes-00#section-3.2 + if (cookie.name.startsWith('__Secure-')) { + cookie.secure = true + } + + if (cookie.name.startsWith('__Host-')) { + cookie.secure = true + cookie.domain = null + cookie.path = '/' + } + + if (cookie.secure) { + out.push('Secure') + } + + if (cookie.httpOnly) { + out.push('HttpOnly') + } + + if (typeof cookie.maxAge === 'number') { + validateCookieMaxAge(cookie.maxAge) + out.push(`Max-Age=${cookie.maxAge}`) + } + + if (cookie.domain) { + validateCookieDomain(cookie.domain) + out.push(`Domain=${cookie.domain}`) + } + + if (cookie.path) { + validateCookiePath(cookie.path) + out.push(`Path=${cookie.path}`) + } + + if (cookie.expires && cookie.expires.toString() !== 'Invalid Date') { + out.push(`Expires=${toIMFDate(cookie.expires)}`) + } + + if (cookie.sameSite) { + out.push(`SameSite=${cookie.sameSite}`) + } + + for (const part of cookie.unparsed) { + if (!part.includes('=')) { + throw new Error('Invalid unparsed') + } + + const [key, ...value] = part.split('=') + + out.push(`${key.trim()}=${value.join('=')}`) + } + + return out.join('; ') +} + +module.exports = { + isCTLExcludingHtab, + stringify +} diff --git a/lib/fetch/headers.js b/lib/fetch/headers.js index 60d6de72a54..f3955f2a47a 100644 --- a/lib/fetch/headers.js +++ b/lib/fetch/headers.js @@ -65,6 +65,9 @@ function fill (headers, object) { } class HeadersList { + /** @type {[string, string][]|null} */ + cookies = null + constructor (init) { if (init instanceof HeadersList) { this[kHeadersMap] = new Map(init[kHeadersMap]) @@ -105,6 +108,11 @@ class HeadersList { } else { this[kHeadersMap].set(lowercaseName, { name, value }) } + + if (lowercaseName === 'set-cookie') { + this.cookies ??= [] + this.cookies.push([name, value]) + } } // https://fetch.spec.whatwg.org/#concept-header-list-set @@ -112,6 +120,10 @@ class HeadersList { this[kHeadersSortedMap] = null const lowercaseName = name.toLowerCase() + if (lowercaseName === 'set-cookie') { + this.cookies = [[name, value]] + } + // 1. If list contains name, then set the value of // the first such header to value and remove the // others. @@ -124,6 +136,11 @@ class HeadersList { this[kHeadersSortedMap] = null name = name.toLowerCase() + + if (name === 'set-cookie') { + this.cookies = null + } + return this[kHeadersMap].delete(name) } diff --git a/package.json b/package.json index d1f5c895005..2de72ae4efd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "build:wasm": "node build/wasm.js --docker", "lint": "standard | snazzy", "lint:fix": "standard --fix | snazzy", - "test": "npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:wpt && npm run test:websocket && npm run test:jest && tsd", + "test": "npm run test:tap && npm run test:node-fetch && npm run test:fetch && npm run test:cookies && npm run test:wpt && npm run test:websocket && npm run test:jest && tsd", + "test:cookies": "node scripts/verifyVersion 18 || tap test/cookie/*.js", "test:node-fetch": "node scripts/verifyVersion.js 16 || mocha test/node-fetch", "test:fetch": "node scripts/verifyVersion.js 16 || (npm run build:node && tap test/fetch/*.js && tap test/webidl/*.js)", "test:jest": "node scripts/verifyVersion.js 14 || jest", diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js new file mode 100644 index 00000000000..846013cbc82 --- /dev/null +++ b/test/cookie/cookies.js @@ -0,0 +1,594 @@ +'use strict' + +const { test } = require('tap') +const { + deleteCookie, + getCookies, + getSetCookies, + setCookie, + Headers +} = require('../..') + +// https://raw.githubusercontent.com/denoland/deno_std/b4239898d6c6b4cdbfd659a4ea1838cf4e656336/http/cookie_test.ts + +test('Cookie parser', (t) => { + let headers = new Headers() + t.same(getCookies(headers), {}) + headers = new Headers() + headers.set('Cookie', 'foo=bar') + t.same(getCookies(headers), { foo: 'bar' }) + + headers = new Headers() + headers.set('Cookie', 'full=of ; tasty=chocolate') + t.same(getCookies(headers), { full: 'of ', tasty: 'chocolate' }) + + headers = new Headers() + headers.set('Cookie', 'igot=99; problems=but...') + t.same(getCookies(headers), { igot: '99', problems: 'but...' }) + + headers = new Headers() + headers.set('Cookie', 'PREF=al=en-GB&f1=123; wide=1; SID=123') + t.same(getCookies(headers), { + PREF: 'al=en-GB&f1=123', + wide: '1', + SID: '123' + }) + + t.end() +}) + +test('Cookie Name Validation', (t) => { + const tokens = [ + '"id"', + 'id\t', + 'i\td', + 'i d', + 'i;d', + '{id}', + '[id]', + '"', + 'id\u0091' + ] + const headers = new Headers() + tokens.forEach((name) => { + t.throws( + () => { + setCookie(headers, { + name, + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 3 + }) + }, + Error + ) + }) + + t.end() +}) + +test('Cookie Value Validation', (t) => { + const tokens = [ + '1f\tWa', + '\t', + '1f Wa', + '1f;Wa', + '"1fWa', + '1f\\Wa', + '1f"Wa', + '"', + '1fWa\u0005', + '1f\u0091Wa' + ] + + const headers = new Headers() + tokens.forEach((value) => { + t.throws( + () => { + setCookie( + headers, + { + name: 'Space', + value, + httpOnly: true, + secure: true, + maxAge: 3 + } + ) + }, + Error, + "RFC2616 cookie 'Space'" + ) + }) + + t.throws( + () => { + setCookie(headers, { + name: 'location', + value: 'United Kingdom' + }) + }, + Error, + "RFC2616 cookie 'location' cannot contain character ' '" + ) + + t.end() +}) + +test('Cookie Path Validation', (t) => { + const path = '/;domain=sub.domain.com' + const headers = new Headers() + t.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + path, + maxAge: 3 + }) + }, + Error, + path + ": Invalid cookie path char ';'" + ) + + t.end() +}) + +test('Cookie Domain Validation', (t) => { + const tokens = ['-domain.com', 'domain.org.', 'domain.org-'] + const headers = new Headers() + tokens.forEach((domain) => { + t.throws( + () => { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + domain, + maxAge: 3 + }) + }, + Error, + 'Invalid first/last char in cookie domain: ' + domain + ) + }) + + t.end() +}) + +test('Cookie Delete', (t) => { + let headers = new Headers() + deleteCookie(headers, 'deno') + t.equal( + headers.get('Set-Cookie'), + 'deno=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + domain: 'deno.land', + path: '/' + }) + deleteCookie(headers, 'Space', { domain: '', path: '' }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Domain=deno.land; Path=/, Space=; Expires=Thu, 01 Jan 1970 00:00:00 GMT' + ) + + t.end() +}) + +test('Cookie Set', (t) => { + let headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat' }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', secure: true }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure') + + headers = new Headers() + setCookie(headers, { name: 'Space', value: 'Cat', httpOnly: true }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true + }) + t.equal(headers.get('Set-Cookie'), 'Space=Cat; Secure; HttpOnly') + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2 + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 0 + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=0' + ) + + let error = false + headers = new Headers() + try { + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: -1 + }) + } catch { + error = true + } + t.ok(error) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; ' + + 'SameSite=Strict' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'unparsed=keyvalue; batman=Bruce' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + httpOnly: true, + secure: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { + name: 'Space', + value: 'Cat', + expires: Date.UTC(1983, 0, 7, 15, 32) + }) + t.equal( + headers.get('Set-Cookie'), + 'Space=Cat; Expires=Fri, 07 Jan 1983 15:32:00 GMT' + ) + + headers = new Headers() + setCookie(headers, { name: '__Secure-Kitty', value: 'Meow' }) + t.equal(headers.get('Set-Cookie'), '__Secure-Kitty=Meow; Secure') + + headers = new Headers() + setCookie(headers, { + name: '__Host-Kitty', + value: 'Meow', + domain: 'deno.land' + }) + t.equal( + headers.get('Set-Cookie'), + '__Host-Kitty=Meow; Secure; Path=/' + ) + + headers = new Headers() + setCookie(headers, { name: 'cookie-1', value: 'value-1', secure: true }) + setCookie(headers, { name: 'cookie-2', value: 'value-2', maxAge: 3600 }) + t.equal( + headers.get('Set-Cookie'), + 'cookie-1=value-1; Secure, cookie-2=value-2; Max-Age=3600' + ) + + headers = new Headers() + setCookie(headers, { name: '', value: '' }) + t.equal(headers.get('Set-Cookie'), null) + + t.end() +}) + +test('Set-Cookie parser', (t) => { + let headers = new Headers({ 'set-cookie': 'Space=Cat' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat' + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; HttpOnly' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + httpOnly: true + }]) + + headers = new Headers({ 'set-cookie': 'Space=Cat; Secure; HttpOnly' }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=0' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 0 + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=-1' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true + }]) + + headers = new Headers({ + 'set-cookie': 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Strict' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Strict' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; SameSite=Lax' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + sameSite: 'Lax' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; unparsed=keyvalue; batman=Bruce' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + unparsed: ['unparsed=keyvalue', 'batman=Bruce'] + }]) + + headers = new Headers({ + 'set-cookie': + 'Space=Cat; Secure; HttpOnly; Max-Age=2; Domain=deno.land; Path=/; ' + + 'Expires=Fri, 07 Jan 1983 15:32:00 GMT' + }) + t.same(getSetCookies(headers), [{ + name: 'Space', + value: 'Cat', + secure: true, + httpOnly: true, + maxAge: 2, + domain: 'deno.land', + path: '/', + expires: new Date(Date.UTC(1983, 0, 7, 15, 32)) + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow; Secure' }) + t.same(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow', + secure: true + }]) + + headers = new Headers({ 'set-cookie': '__Secure-Kitty=Meow' }) + t.same(getSetCookies(headers), [{ + name: '__Secure-Kitty', + value: 'Meow' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/' + }]) + + headers = new Headers({ 'set-cookie': '__Host-Kitty=Meow; Path=/' }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Domain=deno.land; Path=/' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + domain: 'deno.land', + path: '/' + }]) + + headers = new Headers({ + 'set-cookie': '__Host-Kitty=Meow; Secure; Path=/not-root' + }) + t.same(getSetCookies(headers), [{ + name: '__Host-Kitty', + value: 'Meow', + secure: true, + path: '/not-root' + }]) + + headers = new Headers([ + ['set-cookie', 'cookie-1=value-1; Secure'], + ['set-cookie', 'cookie-2=value-2; Max-Age=3600'] + ]) + t.same(getSetCookies(headers), [ + { name: 'cookie-1', value: 'value-1', secure: true }, + { name: 'cookie-2', value: 'value-2', maxAge: 3600 } + ]) + + headers = new Headers() + t.same(getSetCookies(headers), []) + + t.end() +}) From 74e21eb99accabb3728f6737d1164c41fe068742 Mon Sep 17 00:00:00 2001 From: Khafra <42794878+KhafraDev@users.noreply.github.com> Date: Thu, 5 Jan 2023 13:56:15 -0500 Subject: [PATCH 2/4] add types --- index.d.ts | 1 + types/cookies.d.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 types/cookies.d.ts diff --git a/index.d.ts b/index.d.ts index 527c524c1f2..e914634f1d0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -16,6 +16,7 @@ import mockErrors from'./types/mock-errors' import ProxyAgent from'./types/proxy-agent' import { request, pipeline, stream, connect, upgrade } from './types/api' +export * from './types/cookies' export * from './types/fetch' export * from './types/file' export * from './types/filereader' diff --git a/types/cookies.d.ts b/types/cookies.d.ts new file mode 100644 index 00000000000..5b2243440f0 --- /dev/null +++ b/types/cookies.d.ts @@ -0,0 +1,28 @@ +/// + +import type { Headers } from './fetch' + +export interface Cookie { + name: string + value: string + expires?: Date | number + maxAge?: number + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' + unparsed?: string[] +} + +export function deleteCookies ( + headers: Headers, + name: string, + attributes?: { name?: string, domain?: string } +): void + +export function getCookies (headers: Headers): Record + +export function getSetCookies (headers: Headers): Cookie[] + +export function setCookie (headers: Headers, cookie: Cookie): void From cc1d26b219f8d4e40ca60228e5c7d6b74090e735 Mon Sep 17 00:00:00 2001 From: Khafra <42794878+KhafraDev@users.noreply.github.com> Date: Thu, 5 Jan 2023 16:35:22 -0500 Subject: [PATCH 3/4] fix: add deno license to test file --- test/cookie/cookies.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js index 846013cbc82..9177107a24f 100644 --- a/test/cookie/cookies.js +++ b/test/cookie/cookies.js @@ -1,3 +1,5 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + 'use strict' const { test } = require('tap') From aed7558bb610c721e021b15551fbe1b3d482d70b Mon Sep 17 00:00:00 2001 From: Khafra <42794878+KhafraDev@users.noreply.github.com> Date: Fri, 6 Jan 2023 10:44:57 -0500 Subject: [PATCH 4/4] fix: add in full license --- test/cookie/cookies.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test/cookie/cookies.js b/test/cookie/cookies.js index 9177107a24f..70222faa36d 100644 --- a/test/cookie/cookies.js +++ b/test/cookie/cookies.js @@ -1,4 +1,24 @@ -// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. +// MIT License +// +// Copyright 2018-2022 the Deno authors. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. 'use strict'