UNPKG

@ima/core

Version:

IMA.js framework for isomorphic javascript application

474 lines (473 loc) 18 kB
import memoizeOne from 'memoize-one'; import { Storage } from './Storage'; import { GenericError } from '../error/GenericError'; import { Request } from '../router/Request'; import { Response } from '../router/Response'; import { Window } from '../window/Window'; /** * Implementation note: This is the largest possible safe value that has been * tested, used to represent "infinity". */ const MAX_EXPIRE_DATE = new Date('Fri, 31 Dec 9999 23:59:59 UTC'); /** * Separator used to separate cookie declarations in the `Cookie` HTTP * header or the return value of the `document.cookie` property. */ const COOKIE_SEPARATOR = '; '; /** * Storage of cookies, mirroring the cookies to the current request / response * at the server side and the `document.cookie` property at the client * side. The storage caches the cookies internally. */ export class CookieStorage extends Storage { /** * The window utility used to determine whether the IMA is being run * at the client or at the server. */ _window; /** * The current HTTP request. This field is used at the server side. */ _request; /** * The current HTTP response. This field is used at the server side. */ _response; /** * The internal storage of entries. */ _storage = new Map(); /** * The overriding cookie attribute values. */ _options = { path: '/', expires: undefined, maxAge: undefined, secure: false, partitioned: false, httpOnly: false, domain: '', sameSite: 'lax' }; /** * Transform encode and decode functions for cookie value. */ _transformFunction = { encode: (value)=>value, decode: (value)=>value }; /** * Memoized function of private parseRawCookies function */ #memoParseRawCookies = memoizeOne(this.#parseRawCookies); static get $dependencies() { return [ Window, Request, Response ]; } /** * Filters invalid cookies based on the provided url. * We try to check validity of the domain based on secure, path and * domain definitions. */ static validateCookieSecurity(cookie, url) { const { pathname, hostname, protocol } = new URL(url); const secure = protocol === 'https:'; /** * Don't allow setting secure cookies without the secure * defined in the validate options. */ if (typeof cookie.options.secure === 'boolean' && secure !== cookie.options.secure) { return false; } /** * Don't allow setting cookies with a path that doesn't start with * the path defined in the validate options. Root path is always valid. */ if (typeof cookie.options.path === 'string' && cookie.options.path !== '/') { const pathChunks = pathname.split('/'); const cookiePathChunks = cookie.options.path.split('/'); /** * Compare the path chunks of the request path and the cookie path. */ for(let i = 0; i < cookiePathChunks.length; i++){ /** * There are no more path chunks to compare, so the cookie path is a * prefix of the request path. */ if (cookiePathChunks[i] === undefined) { break; } /** * The path chunks don't match, the cookie path is not a prefix of * the request path. */ if (pathChunks[i] !== cookiePathChunks[i]) { return false; } } } /** * Domain security check, we also check for subdomain match. */ if (cookie.options.domain) { const cookieDomain = cookie.options.domain.toLowerCase(); const normalizedCookieDomain = cookieDomain.startsWith('.') ? cookieDomain.slice(1) : cookieDomain; return normalizedCookieDomain === hostname || hostname.endsWith(normalizedCookieDomain) ? true : false; } return true; } /** * Initializes the cookie storage. * * @param window The window utility. * @param request The current HTTP request. * @param response The current HTTP response. * @example * cookie.set('cookie', 'value', { expires: 10 }); // cookie expires * // after 10s * cookie.set('cookie'); // delete cookie * */ constructor(window, request, response){ super(); this._window = window; this._request = request; this._response = response; } /** * @inheritDoc */ init(options = {}, transformFunction = {}) { this._transformFunction = Object.assign(this._transformFunction, transformFunction); this._options = Object.assign(this._options, options); this.parse(); return this; } /** * @inheritDoc */ has(name) { this.parse(); return this._storage.has(name); } /** * @inheritDoc */ get(name) { this.parse(); return this._storage.has(name) ? this._storage.get(name).value : undefined; } /** * @inheritDoc * @param name The key identifying the storage entry. * @param value The storage entry value. * @param options The cookie options. The `maxAge` is the maximum * age in seconds of the cookie before it will be deleted, the * `expires` is an alternative to that, specifying the moment * at which the cookie will be discarded. The `domain` and * `path` specify the cookie's domain and path. The * `httpOnly` and `secure` flags set the flags of the * same name of the cookie. */ set(name, value, options = {}) { options = Object.assign({}, this._options, options); if (value === undefined) { // Deletes the cookie options.maxAge = 0; options.expires = this.getExpirationAsDate(-1); } else { this.recomputeCookieMaxAgeAndExpires(options); } value = this.sanitizeCookieValue(value + ''); if (this._window.isClient()) { document.cookie = this.#generateCookieString(name, value, options); } else { this._response.setCookie(name, value, options); } this._storage.set(name, { value, options }); return this; } /** * Deletes the cookie identified by the specified name. * * @param name Name identifying the cookie. * @param options The cookie options. The `domain` and * `path` specify the cookie's domain and path. The * `httpOnly` and `secure` flags set the flags of the * same name of the cookie. * @return This storage. */ delete(name, options = {}) { if (this._storage.has(name)) { this.set(name, undefined, options); this._storage.delete(name); } return this; } /** * @inheritDoc */ clear() { for (const cookieName of this._storage.keys()){ this.delete(cookieName); } this._storage.clear(); return this; } /** * @inheritDoc */ keys() { this.parse(); return this._storage.keys(); } /** * @inheritDoc */ size() { this.parse(); return this._storage.size; } /** * Returns all cookies in this storage serialized to a string compatible * with the `Cookie` HTTP header. * * When `url` is provided, the method validates the cookie security based on * the `url` and the cookie's domain, path, and secure attributes. * * @return All cookies in this storage serialized to a string * compatible with the `Cookie` HTTP header. */ getCookiesStringForCookieHeader(url) { const cookieStrings = []; for (const cookieName of this._storage.keys()){ const cookieItem = this._storage.get(cookieName); /** * Skip cookies that are not secure for the provided url. */ if (url && cookieItem && !CookieStorage.validateCookieSecurity(cookieItem, url)) { continue; } cookieStrings.push(this.#generateCookieString(cookieName, cookieItem.value, {})); } return cookieStrings.join(COOKIE_SEPARATOR); } /** * Parses cookies from the provided `Set-Cookie` HTTP header value. * * When `url` is provided, the method validates the cookie security based on * the `url` and the cookie's domain, path, and secure attributes. * * The parsed cookies will be set to the internal storage, and the current * HTTP response (via the `Set-Cookie` HTTP header) if at the server * side, or the browser (via the `document.cookie` property). * * @param cookiesString The value of the `Set-Cookie` HTTP * header. When there are multiple cookies, the value can be * provided as an array of strings. */ parseFromSetCookieHeader(cookiesString, url) { const cookiesArray = Array.isArray(cookiesString) ? cookiesString : [ cookiesString ]; for (const cookie of cookiesArray){ const cookieItem = this.#extractCookie(cookie); /** * Skip cookies that are not secure for the provided url. */ if (url && cookieItem && !CookieStorage.validateCookieSecurity(cookieItem, url)) { continue; } if (typeof cookieItem.name === 'string') { this.set(cookieItem.name, cookieItem.value, cookieItem.options); } } } /** * Parses cookies from a cookie string and sets the parsed cookies to the * internal storage. * * The method obtains the cookie string from the request's `Cookie` * HTTP header when used at the server side, and the `document.cookie` * property at the client side. */ parse() { const cookiesNames = this.#memoParseRawCookies(this._window.isClient() ? document.cookie : this._request.getCookieHeader()); // remove cookies from storage, which were not parsed for (const storageCookieName of this._storage.keys()){ const index = cookiesNames.indexOf(storageCookieName); if (index === -1) { this._storage.delete(storageCookieName); } } } /** * Sanitize cookie value by rules in * (@see http://tools.ietf.org/html/rfc6265#section-4r.1.1). Erase all * invalid characters from cookie value. * * @param value Cookie value * @return Sanitized value */ sanitizeCookieValue(value) { let sanitizedValue = ''; if (typeof value !== 'string') { return sanitizedValue; } for(let keyChar = 0; keyChar < value.length; keyChar++){ const charCode = value.charCodeAt(keyChar); const char = value[keyChar]; const isValid = charCode >= 33 && charCode <= 126 && char !== '"' && char !== ';' && char !== '\\'; if (isValid) { sanitizedValue += char; } else { if ($Debug) { throw new GenericError(`Invalid char ${char} code ${charCode} in ${value}. ` + `Dropping the invalid character from the cookie's ` + `value.`, { value, charCode, char }); } } } return sanitizedValue; } /** * Recomputes cookie's attributes maxAge and expires between each other. * * @param options Cookie attributes. Only the attributes listed in the * type annotation of this field are supported. For documentation * and full list of cookie attributes see * http://tools.ietf.org/html/rfc2965#page-5 */ recomputeCookieMaxAgeAndExpires(options) { if (options.maxAge || options.expires) { options.expires = this.getExpirationAsDate(options.maxAge || options.expires); } if (!options.maxAge && options.expires) { options.maxAge = Math.floor((options.expires.valueOf() - Date.now()) / 1000); } } /** * Converts the provided cookie expiration to a `Date` instance. * * @param expiration Cookie expiration in seconds * from now, or as a string compatible with the `Date` * constructor. * @return Cookie expiration as a `Date` instance. */ getExpirationAsDate(expiration) { if (expiration instanceof Date) { return expiration; } if (typeof expiration === 'number') { return expiration === Infinity ? MAX_EXPIRE_DATE : new Date(Date.now() + expiration * 1000); } return expiration ? new Date(expiration) : MAX_EXPIRE_DATE; } #parseRawCookies(rawCookies) { const cookiesArray = rawCookies ? rawCookies.split(COOKIE_SEPARATOR) : []; const cookiesNames = []; for(let i = 0; i < cookiesArray.length; i++){ const cookie = this.#extractCookie(cookiesArray[i]); if (typeof cookie.name === 'string') { // if cookie already exists in storage get its old options let oldCookieOptions = {}; if (this._storage.has(cookie.name)) { oldCookieOptions = this._storage.get(cookie.name).options; } cookie.options = Object.assign({}, this._options, oldCookieOptions, cookie.options // new cookie options (if any) ); cookiesNames.push(cookie.name); // add new cookie or update existing one this._storage.set(cookie.name, { value: this.sanitizeCookieValue(cookie.value), options: cookie.options }); } } return cookiesNames; } /** * Creates a copy of the provided word (or text) that has its first * character converted to lower case. * * @param word The word (or any text) that should have its first * character converted to lower case. * @return A copy of the provided string with its first character * converted to lower case. */ #firstLetterToLowerCase(word) { return word.charAt(0).toLowerCase() + word.substring(1); } /** * Generates a string representing the specified cookie, usable either * with the `document.cookie` property or the `Set-Cookie` HTTP * header. * * (Note that the `Cookie` HTTP header uses a slightly different * syntax.) * * @param name The cookie name. * @param value The cookie value, will be * converted to string. * @param options Cookie attributes. Only the attributes listed in the * type annotation of this field are supported. For documentation * and full list of cookie attributes see * http://tools.ietf.org/html/rfc2965#page-5 * @return A string representing the cookie. Setting this string * to the `document.cookie` property will set the cookie to * the browser's cookie storage. */ #generateCookieString(name, value, options) { let cookieString = name + '=' + this._transformFunction.encode(value); cookieString += options.domain ? ';Domain=' + options.domain : ''; cookieString += options.path ? ';Path=' + options.path : ''; cookieString += options.expires ? ';Expires=' + options.expires.toUTCString() : ''; cookieString += options.maxAge ? ';Max-Age=' + options.maxAge : ''; cookieString += options.httpOnly ? ';HttpOnly' : ''; cookieString += options.secure ? ';Secure' : ''; cookieString += options.partitioned ? ';Partitioned' : ''; cookieString += options.sameSite ? ';SameSite=' + options.sameSite : ''; return cookieString; } /** * Extract cookie name, value and options from cookie string. * * @param cookieString The value of the `Set-Cookie` HTTP * header. */ #extractCookie(cookieString) { const cookieOptions = {}; let cookieName; let cookieValue; cookieString.split(COOKIE_SEPARATOR.trim()).forEach((pair, index)=>{ const [name, value] = this.#extractNameAndValue(pair, index); if (!name) { return; } if (index === 0) { cookieName = name; cookieValue = value; } else { Object.assign(cookieOptions, { [name]: value }); } }); return { name: cookieName, value: cookieValue, options: cookieOptions }; } /** * Extract name and value for defined pair and pair index. */ #extractNameAndValue(pair, pairIndex) { const separatorIndexEqual = pair.indexOf('='); let name = ''; let value = null; if (pairIndex === 0 && separatorIndexEqual < 0) { return [ null, null ]; } if (separatorIndexEqual < 0) { name = pair.trim(); value = true; } else { name = pair.substring(0, separatorIndexEqual).trim(); value = this._transformFunction.decode(pair.substring(separatorIndexEqual + 1).trim()); // erase quoted values if ('"' === value[0]) { value = value.slice(1, -1); } if (name === 'Expires') { value = this.getExpirationAsDate(value); } if (name === 'Max-Age') { name = 'maxAge'; value = parseInt(value, 10); } } if (pairIndex !== 0) { name = this.#firstLetterToLowerCase(name); } return [ name, value ]; } } //# sourceMappingURL=CookieStorage.js.map