cookies.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. 'use strict';
  2. const assert = require('assert');
  3. const utility = require('utility');
  4. const _isSameSiteNoneCompatible = require('should-send-same-site-none').isSameSiteNoneCompatible;
  5. const Keygrip = require('./keygrip');
  6. const Cookie = require('./cookie');
  7. const CookieError = require('./error');
  8. const KEYS_ARRAY = Symbol('eggCookies:keysArray');
  9. const KEYS = Symbol('eggCookies:keys');
  10. const keyCache = new Map();
  11. /**
  12. * cookies for egg
  13. * extend pillarjs/cookies, add encrypt and decrypt
  14. */
  15. class Cookies {
  16. constructor(ctx, keys, defaultCookieOptions) {
  17. this[KEYS_ARRAY] = keys;
  18. this._keys = keys;
  19. // default cookie options
  20. this._defaultCookieOptions = defaultCookieOptions;
  21. this.ctx = ctx;
  22. this.secure = this.ctx.secure;
  23. this.app = ctx.app;
  24. }
  25. get keys() {
  26. if (!this[KEYS]) {
  27. const keysArray = this[KEYS_ARRAY];
  28. assert(Array.isArray(keysArray), '.keys required for encrypt/sign cookies');
  29. const cache = keyCache.get(keysArray);
  30. if (cache) {
  31. this[KEYS] = cache;
  32. } else {
  33. this[KEYS] = new Keygrip(this[KEYS_ARRAY]);
  34. keyCache.set(keysArray, this[KEYS]);
  35. }
  36. }
  37. return this[KEYS];
  38. }
  39. /**
  40. * get cookie value by name
  41. * @param {String} name - cookie's name
  42. * @param {Object} opts - cookies' options
  43. * - {Boolean} signed - default to true
  44. * - {Boolean} encrypt - default to false
  45. * @return {String} value - cookie's value
  46. */
  47. get(name, opts) {
  48. opts = opts || {};
  49. const signed = computeSigned(opts);
  50. const header = this.ctx.get('cookie');
  51. if (!header) return;
  52. const match = header.match(getPattern(name));
  53. if (!match) return;
  54. let value = match[1];
  55. if (!opts.encrypt && !signed) return value;
  56. // signed
  57. if (signed) {
  58. const sigName = name + '.sig';
  59. const sigValue = this.get(sigName, { signed: false });
  60. if (!sigValue) return;
  61. const raw = name + '=' + value;
  62. const index = this.keys.verify(raw, sigValue);
  63. if (index < 0) {
  64. // can not match any key, remove ${name}.sig
  65. this.set(sigName, null, { path: '/', signed: false, overwrite: true });
  66. return;
  67. }
  68. if (index > 0) {
  69. // not signed by the first key, update sigValue
  70. this.set(sigName, this.keys.sign(raw), { signed: false, overwrite: true });
  71. }
  72. return value;
  73. }
  74. // encrypt
  75. value = utility.base64decode(value, true, 'buffer');
  76. const res = this.keys.decrypt(value);
  77. return res ? res.value.toString() : undefined;
  78. }
  79. set(name, value, opts) {
  80. opts = Object.assign({}, this._defaultCookieOptions, opts);
  81. const signed = computeSigned(opts);
  82. value = value || '';
  83. if (!this.secure && opts.secure) {
  84. throw new CookieError('Cannot send secure cookie over unencrypted connection');
  85. }
  86. let headers = this.ctx.response.get('set-cookie') || [];
  87. if (!Array.isArray(headers)) headers = [ headers ];
  88. // encrypt
  89. if (opts.encrypt) {
  90. value = value && utility.base64encode(this.keys.encrypt(value), true);
  91. }
  92. // http://browsercookielimits.squawky.net/
  93. if (value.length > 4093) {
  94. this.app.emit('cookieLimitExceed', { name, value, ctx: this.ctx });
  95. }
  96. // https://github.com/linsight/should-send-same-site-none
  97. // fixed SameSite=None: Known Incompatible Clients
  98. if (opts.sameSite && typeof opts.sameSite === 'string' && opts.sameSite.toLowerCase() === 'none') {
  99. const userAgent = this.ctx.get('user-agent');
  100. if (!this.secure || (userAgent && !this.isSameSiteNoneCompatible(userAgent))) {
  101. // Non-secure context or Incompatible clients, don't send SameSite=None property
  102. opts.sameSite = false;
  103. }
  104. }
  105. const cookie = new Cookie(name, value, opts);
  106. // if user not set secure, reset secure to ctx.secure
  107. if (opts.secure === undefined) cookie.attrs.secure = this.secure;
  108. headers = pushCookie(headers, cookie);
  109. // signed
  110. if (signed) {
  111. cookie.value = value && this.keys.sign(cookie.toString());
  112. cookie.name += '.sig';
  113. headers = pushCookie(headers, cookie);
  114. }
  115. this.ctx.set('set-cookie', headers);
  116. return this;
  117. }
  118. isSameSiteNoneCompatible(userAgent) {
  119. // Chrome >= 80.0.0.0
  120. const result = parseChromiumAndMajorVersion(userAgent);
  121. if (result.chromium) return result.majorVersion >= 80;
  122. return _isSameSiteNoneCompatible(userAgent);
  123. }
  124. }
  125. // https://github.com/linsight/should-send-same-site-none/blob/master/index.js#L86
  126. function parseChromiumAndMajorVersion(userAgent) {
  127. const m = /Chrom[^ \/]{1,100}\/(\d{1,100}?)\./.exec(userAgent);
  128. if (!m) return { chromium: false, version: null };
  129. // Extract digits from first capturing group.
  130. return { chromium: true, majorVersion: parseInt(m[1]) };
  131. }
  132. const partternCache = new Map();
  133. function getPattern(name) {
  134. const cache = partternCache.get(name);
  135. if (cache) return cache;
  136. const reg = new RegExp(
  137. '(?:^|;) *' +
  138. name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') +
  139. '=([^;]*)'
  140. );
  141. partternCache.set(name, reg);
  142. return reg;
  143. }
  144. function computeSigned(opts) {
  145. // encrypt default to false, signed default to true.
  146. // disable singed when encrypt is true.
  147. if (opts.encrypt) return false;
  148. return opts.signed !== false;
  149. }
  150. function pushCookie(cookies, cookie) {
  151. if (cookie.attrs.overwrite) {
  152. cookies = cookies.filter(c => !c.startsWith(cookie.name + '='));
  153. }
  154. cookies.push(cookie.toHeader());
  155. return cookies;
  156. }
  157. Cookies.CookieError = CookieError;
  158. module.exports = Cookies;