mailauth 7.1.0 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,11 @@
3
3
  const { formatDomain, toALabel } = require('../tools');
4
4
 
5
5
  // Characters that can not appear in a domain name taken from an address. Anything like this
6
- // means the address was malformed and the text after the "@" is not a domain.
7
- const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]]/;
6
+ // means the address was malformed and the text after the "@" is not a domain. Control characters
7
+ // (Cc, including DEL) and invisible format characters (Cf, such as U+200B or the soft hyphen) are
8
+ // not valid in a U-label (RFC 5890 2.3.2.1, RFC 5892), so such a domain has no A-label. U+200C and
9
+ // U+200D are left out, IDNA2008 allows them in some scripts (CONTEXTJ).
10
+ const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]\p{Cc}]|[^\P{Cf}\u200c\u200d]/u;
8
11
 
9
12
  // A domain literal (RFC 5322 3.4.1). It is kept as is, it can not publish a DMARC record.
10
13
  const DOMAIN_LITERAL = /^\[[^[\]\\]*\]$/;
@@ -137,17 +140,31 @@ const getAddressDomains = address => {
137
140
  // "fromFields" is the number of From header fields the addresses came from, if known. RFC 5322
138
141
  // allows only one, and with several the DKIM signature can cover a different field than the one
139
142
  // a mail client shows, so their addresses are not merged into one Author Domain.
140
- const getAuthorDomain = (headerFrom, fromFields) => {
143
+ // "opts.fromSyntax" is the syntax of the From header field (see parseFromHeader). An "invalid"
144
+ // field, or a "lax" one with "opts.strict", gives no Author Domain, as the addresses may not be
145
+ // the ones a reader sees. For backward compatibility "opts" can also be a boolean that is true
146
+ // for a From header field that could not be parsed.
147
+ const getAuthorDomain = (headerFrom, fromFields, opts) => {
148
+ let fromInvalid = opts;
149
+ if (opts && typeof opts === 'object') {
150
+ fromInvalid = opts.fromSyntax === 'invalid' || (!!opts.strict && opts.fromSyntax === 'lax');
151
+ }
152
+
141
153
  let addresses = [].concat(headerFrom === undefined || headerFrom === null ? [] : headerFrom);
142
154
 
143
155
  let found = new Map();
144
156
  let invalid = false;
157
+ // a mailbox has a domain part, but it is not a domain name
158
+ let malformed = !!fromInvalid;
145
159
  for (let address of addresses) {
146
160
  let entries = getAddressDomains(address);
147
161
  if (!entries.length) {
148
162
  // a mailbox without a usable domain is not skipped, the domain a reader sees for it
149
163
  // is unknown, so the Author Domain can not be determined
150
164
  invalid = true;
165
+ if (typeof address === 'string' && address.indexOf('@') >= 0 && address.slice(address.lastIndexOf('@') + 1).replace(/^\s*\.?\s*$/, '')) {
166
+ malformed = true;
167
+ }
151
168
  }
152
169
  for (let entry of entries) {
153
170
  if (!found.has(entry.authorDomain)) {
@@ -162,7 +179,7 @@ const getAuthorDomain = (headerFrom, fromFields) => {
162
179
  return { reason: 'multiple-from-fields', domains: Array.from(found.keys()), mailboxes };
163
180
  }
164
181
 
165
- if (invalid && found.size) {
182
+ if (malformed || (invalid && found.size)) {
166
183
  return { reason: 'invalid-author-domain', domains: Array.from(found.keys()), mailboxes };
167
184
  }
168
185
 
@@ -178,4 +195,252 @@ const getAuthorDomain = (headerFrom, fromFields) => {
178
195
  };
179
196
  };
180
197
 
181
- module.exports = { getAuthorDomain, getAddressDomains };
198
+ // RFC 5322 3.2.3 atext, with the UTF-8 of RFC 6532 3.2
199
+ const ATEXT = /[A-Za-z0-9!#$%&'*+\-/=?^_`{|}~\u0080-￿]/;
200
+
201
+ // ASCII control characters other than whitespace. RFC 5322 4.1 only allows them (obs-NO-WS-CTL)
202
+ // in comments, quoted strings and domain literals
203
+ const ASCII_CTL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
204
+
205
+ class FromSyntaxError extends Error {}
206
+
207
+ // Splits a From header field body into atoms, quoted strings, domain literals and single special
208
+ // characters. Whitespace and comments (CFWS) are dropped: where RFC 5322 allows CFWS, including
209
+ // between the atoms of an obs-domain (4.4), it has no meaning, and where it is not allowed, the
210
+ // tokens around it are not valid next to each other anyway.
211
+ const tokenizeFrom = value => {
212
+ let tokens = [];
213
+ let i = 0;
214
+
215
+ // reads a quoted string, a comment or a domain literal that starts at "i"
216
+ let readEnclosed = (close, nests) => {
217
+ let start = i;
218
+ let depth = 0;
219
+ for (; i < value.length; i++) {
220
+ let c = value.charAt(i);
221
+ if (c === '\\' && close !== ']') {
222
+ i++;
223
+ } else if (nests && c === '(') {
224
+ depth++;
225
+ } else if (c === close && (!nests || !--depth)) {
226
+ i++;
227
+ return value.slice(start, i);
228
+ } else if (close === ']' && i > start && (c === '[' || c === '\\')) {
229
+ break;
230
+ }
231
+ }
232
+ throw new FromSyntaxError('unterminated');
233
+ };
234
+
235
+ while (i < value.length) {
236
+ let c = value.charAt(i);
237
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
238
+ i++;
239
+ } else if (c === '(') {
240
+ readEnclosed(')', true);
241
+ } else if (c === '"') {
242
+ i++;
243
+ tokens.push({ type: 'quoted', value: '"' + readEnclosed('"') });
244
+ } else if (c === '[') {
245
+ tokens.push({ type: 'literal', value: readEnclosed(']') });
246
+ } else if (ATEXT.test(c)) {
247
+ let start = i;
248
+ while (i < value.length && ATEXT.test(value.charAt(i))) {
249
+ i++;
250
+ }
251
+ tokens.push({ type: 'atom', value: value.slice(start, i) });
252
+ } else if (ASCII_CTL.test(c)) {
253
+ throw new FromSyntaxError('control character');
254
+ } else {
255
+ // one of the specials ) < > ] : ; @ \ , .
256
+ tokens.push({ type: c });
257
+ i++;
258
+ }
259
+ }
260
+
261
+ return tokens;
262
+ };
263
+
264
+ // Parses the body of a From header field (RFC 5322 3.6.2 and 4.5.2, a mailbox-list or, by
265
+ // RFC 6854, an address-list) and returns the addr-spec of every mailbox, with CFWS removed and
266
+ // without a source route. "syntax" is "valid", "lax" for a field that only the lenient parsing
267
+ // accepts, or "invalid". An invalid field gives no reliable Author Domain (RFC 9989 4.4), so a
268
+ // domain that is not the one a reader sees can not be evaluated in its place.
269
+ //
270
+ // The lenient parsing accepts:
271
+ // - a display name without an address, as in "Doe, John <john@example.com>"
272
+ // - a group that is not closed with ";" at the end of the field
273
+ // - a domain literal or a leading "." in a display name
274
+ // - a domain with a trailing dot
275
+ // - an addr-spec in place of a display name, as in "a@example.com <a@example.com>". Its address
276
+ // is returned too when it differs from the angle-addr, so that a reader who sees it as the
277
+ // author does not get the other domain evaluated
278
+ // Anything else that is not RFC 5322 syntax, such as text after an angle-addr or two addresses
279
+ // without a comma between them, makes the field invalid.
280
+ const parseFromHeader = value => {
281
+ let addresses = [];
282
+ let lax = false;
283
+ let tokens;
284
+ let pos = 0;
285
+
286
+ let peek = () => tokens[pos]?.type;
287
+ let expect = type => {
288
+ if (peek() !== type) {
289
+ throw new FromSyntaxError(`expected "${type}"`);
290
+ }
291
+ return tokens[pos++];
292
+ };
293
+
294
+ // domain = dot-atom / domain-literal / obs-domain
295
+ let parseDomain = () => {
296
+ if (peek() === 'literal') {
297
+ return tokens[pos++].value;
298
+ }
299
+ let domain = expect('atom').value;
300
+ while (peek() === '.') {
301
+ pos++;
302
+ if (peek() !== 'atom') {
303
+ // a trailing dot, as in "user@example.com."
304
+ lax = true;
305
+ return domain + '.';
306
+ }
307
+ domain += '.' + tokens[pos++].value;
308
+ }
309
+ return domain;
310
+ };
311
+
312
+ // local-part = dot-atom / quoted-string / obs-local-part (word *("." word))
313
+ let toLocalPart = words => {
314
+ if (!words.length || words.length % 2 === 0) {
315
+ throw new FromSyntaxError('invalid local-part');
316
+ }
317
+ for (let i = 0; i < words.length; i++) {
318
+ // words and dots alternate, anything else between two words (such as a domain
319
+ // literal) is not a local-part
320
+ let valid = i % 2 === 0 ? words[i].type === 'atom' || words[i].type === 'quoted' : words[i].type === '.';
321
+ if (!valid) {
322
+ throw new FromSyntaxError('invalid local-part');
323
+ }
324
+ }
325
+ return words.map(word => (word.type === '.' ? '.' : word.value)).join('');
326
+ };
327
+
328
+ let parseAddrSpec = () => {
329
+ let words = [];
330
+ while (['atom', 'quoted', '.'].includes(peek())) {
331
+ words.push(tokens[pos++]);
332
+ }
333
+ let localPart = toLocalPart(words);
334
+ expect('@');
335
+ return `${localPart}@${parseDomain()}`;
336
+ };
337
+
338
+ // angle-addr, or obs-angle-addr with a source route that is dropped
339
+ let parseAngleAddr = () => {
340
+ expect('<');
341
+ if (peek() === '@') {
342
+ // obs-route = obs-domain-list ":"
343
+ while (peek() === '@' || peek() === ',') {
344
+ if (tokens[pos++].type === '@') {
345
+ parseDomain();
346
+ }
347
+ }
348
+ expect(':');
349
+ }
350
+ let address = parseAddrSpec();
351
+ expect('>');
352
+ return address;
353
+ };
354
+
355
+ let parseMailbox = inGroup => {
356
+ // display-name (phrase or obs-phrase), or the local-part of an addr-spec
357
+ let words = [];
358
+ while (['atom', 'quoted', '.', 'literal'].includes(peek())) {
359
+ words.push(tokens[pos++]);
360
+ }
361
+
362
+ let next = peek();
363
+
364
+ if (next === '@') {
365
+ let localPart = toLocalPart(words);
366
+ pos++;
367
+ let address = `${localPart}@${parseDomain()}`;
368
+ if (peek() !== '<') {
369
+ addresses.push(address);
370
+ return;
371
+ }
372
+ // an addr-spec used as a display name
373
+ lax = true;
374
+ let angleAddress = parseAngleAddr();
375
+ if (address.toLowerCase() !== angleAddress.toLowerCase()) {
376
+ addresses.push(address);
377
+ }
378
+ addresses.push(angleAddress);
379
+ return;
380
+ }
381
+
382
+ if (words.some(word => word.type === 'literal') || words[0]?.type === '.') {
383
+ lax = true;
384
+ }
385
+
386
+ if (next === '<') {
387
+ addresses.push(parseAngleAddr());
388
+ return;
389
+ }
390
+
391
+ if (next === ':' && !inGroup && words.length) {
392
+ // group = display-name ":" [group-list] ";" [CFWS]
393
+ pos++;
394
+ for (;;) {
395
+ while (peek() === ',') {
396
+ pos++;
397
+ }
398
+ if (peek() === ';') {
399
+ pos++;
400
+ return;
401
+ }
402
+ if (!peek()) {
403
+ lax = true;
404
+ return;
405
+ }
406
+ parseMailbox(true);
407
+ if (peek() !== ',' && peek() !== ';' && peek()) {
408
+ throw new FromSyntaxError('unexpected text in group');
409
+ }
410
+ }
411
+ }
412
+
413
+ if (!words.length) {
414
+ throw new FromSyntaxError('unexpected character');
415
+ }
416
+
417
+ // a display name without an address
418
+ lax = true;
419
+ };
420
+
421
+ try {
422
+ tokens = tokenizeFrom((value || '').toString());
423
+ for (;;) {
424
+ // obs-mbox-list allows empty list elements
425
+ while (peek() === ',') {
426
+ pos++;
427
+ }
428
+ if (!peek()) {
429
+ break;
430
+ }
431
+ parseMailbox(false);
432
+ if (peek() && peek() !== ',') {
433
+ throw new FromSyntaxError('unexpected text after address');
434
+ }
435
+ }
436
+ } catch (err) {
437
+ if (!(err instanceof FromSyntaxError)) {
438
+ throw err;
439
+ }
440
+ return { addresses, syntax: 'invalid' };
441
+ }
442
+
443
+ return { addresses, syntax: lax ? 'lax' : 'valid' };
444
+ };
445
+
446
+ module.exports = { getAuthorDomain, getAddressDomains, parseFromHeader };
@@ -102,15 +102,12 @@ const parseDmarcRecord = (txt, opts) => {
102
102
  let entries = txt
103
103
  .split(';')
104
104
  .map(e => e.trim())
105
- .filter(e => e)
105
+ // a fragment without "=" or without a tag name is not a tag, and syntax errors are discarded
106
+ // (RFC 9989 4.8), so it can not override a valid tag of the same name. The version tag
107
+ // always has a name and "=", the record matched DMARC_VERSION_RE.
108
+ .filter(e => e.indexOf('=') > 0)
106
109
  .map((e, i) => {
107
110
  let splitPos = e.indexOf('=');
108
- if (splitPos < 0) {
109
- // not a tag, a syntax error that is ignored
110
- return [e.toLowerCase().trim(), false];
111
- } else if (splitPos === 0) {
112
- return [false, e];
113
- }
114
111
  // whitespace is allowed around "=" (*WSP in the ABNF), so the value must be trimmed
115
112
  let name = e.substr(0, splitPos).trim();
116
113
  let key = name.toLowerCase();
@@ -156,11 +153,23 @@ const fetchRecord = async (name, resolver, opts) => {
156
153
  return false;
157
154
  }
158
155
 
159
- let records;
156
+ let records = [];
157
+ // set when a record only starts with the version tag after leading whitespace is removed
158
+ let trimmed = false;
160
159
  try {
161
160
  let txt = await resolver(`_dmarc.${name}`, 'TXT');
162
- // a custom resolver might return plain strings instead of lists of character-strings
163
- records = (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trim()).filter(row => DMARC_VERSION_RE.test(row));
161
+ // a custom resolver might return plain strings instead of lists of character-strings.
162
+ // Trailing whitespace belongs to the last tag value, which is trimmed anyway.
163
+ for (let row of (txt || []).map(row => (Array.isArray(row) ? row.join('') : String(row)).trimEnd())) {
164
+ if (DMARC_VERSION_RE.test(row)) {
165
+ records.push(row);
166
+ } else if (!opts?.strict && DMARC_VERSION_RE.test(row.trimStart())) {
167
+ // a record must start with the "v" tag (RFC 9989 4.10), the default mode
168
+ // also accepts one with whitespace before it
169
+ records.push(row.trimStart());
170
+ trimmed = true;
171
+ }
172
+ }
164
173
  } catch (err) {
165
174
  if (NO_RECORD_CODES.has(err.code)) {
166
175
  return false;
@@ -169,7 +178,11 @@ const fetchRecord = async (name, resolver, opts) => {
169
178
  }
170
179
 
171
180
  // a record that is not a valid tag-list is discarded like one that is not a DMARC record
172
- return (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
181
+ let record = (records.length === 1 && parseDmarcRecord(records[0], opts)) || false;
182
+ if (record && trimmed) {
183
+ opts?.warnings?.add('record-whitespace');
184
+ }
185
+ return record;
173
186
  };
174
187
 
175
188
  // Tree Walks for one message share most of their names, so each name is queried only once.
@@ -22,7 +22,7 @@ const shareParent = (a, b) => a.split('.').pop() === b.split('.').pop();
22
22
  const setHidden = (obj, key, value) => Object.defineProperty(obj, key, { value, enumerable: false, configurable: true, writable: true });
23
23
 
24
24
  const evaluateDmarc = async opts => {
25
- let { headerFrom, fromFields, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
25
+ let { headerFrom, fromFields, fromSyntax, spfDomains, dkimDomains, resolver, arcResult, strict } = opts;
26
26
 
27
27
  strict = !!strict;
28
28
  resolver = resolver || dns.resolve;
@@ -30,7 +30,8 @@ const evaluateDmarc = async opts => {
30
30
  // RFC 9989 5.3.1: several mailboxes are fine if they share one domain, but with zero or more
31
31
  // than one Author Domain DMARC validation is not possible. The response stays false, as it
32
32
  // always was for these messages, and "reason" says why.
33
- const author = getAuthorDomain(headerFrom, fromFields);
33
+ // a From header field that only the lenient parsing accepts is invalid in strict mode
34
+ const author = getAuthorDomain(headerFrom, fromFields, { fromSyntax, strict });
34
35
  if (!author.authorDomain) {
35
36
  return { response: false, reason: author.reason, authorDomains: author.domains };
36
37
  }
@@ -41,6 +42,9 @@ const evaluateDmarc = async opts => {
41
42
 
42
43
  // what the default mode accepted that strict mode would not
43
44
  const warnings = new Set();
45
+ if (fromSyntax === 'lax') {
46
+ warnings.add('from-syntax');
47
+ }
44
48
 
45
49
  let formatResponse = response => {
46
50
  if (warnings.size) {
@@ -64,7 +68,7 @@ const evaluateDmarc = async opts => {
64
68
  comment: false,
65
69
  // ptype properties
66
70
  header: {
67
- // RFC 8601 2.7.2: header.from is the domain of the RFC5322.From address,
71
+ // RFC 9989 9.1: header.from is the domain of the RFC5322.From address,
68
72
  // not the organizational domain the policy may have been inherited from
69
73
  from: domain
70
74
  }
package/lib/mailauth.js CHANGED
@@ -7,7 +7,7 @@ const { dmarc, evaluateDmarc } = require('./dmarc');
7
7
  const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
8
8
  const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
9
9
  const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
10
- const { parseReceived, getClientAddress } = require('./parse-received');
10
+ const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
11
11
  const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
12
12
  const libmime = require('libmime');
13
13
  const os = require('node:os');
@@ -60,17 +60,20 @@ const authenticate = async (input, opts) => {
60
60
  if (opts.trustReceived) {
61
61
  let rcvd = receivedChain?.find(row => row.from?.value);
62
62
  if (rcvd) {
63
- let helo = rcvd.from.value;
63
+ let helo = getClientHelo(rcvd);
64
64
  // the address of the connection from the TCP-info part of the from clause
65
65
  // (RFC 5321 section 4.4), never an address the client claimed in its HELO
66
- let ip = getClientAddress(rcvd.from);
66
+ let ip = getClientAddress(rcvd);
67
67
 
68
- if (ip && !opts.ip) {
68
+ // the HELO name of the header belongs to its connection, it is not combined with
69
+ // an address the caller provided
70
+ let callerIp = !!opts.ip;
71
+
72
+ if (ip && !callerIp) {
69
73
  opts.ip = ip;
70
74
  }
71
75
 
72
- if (helo && !opts.helo && !opts.ip) {
73
- // if IP was provided then do not use helo even if it is missing
76
+ if (helo && !opts.helo && !callerIp) {
74
77
  opts.helo = helo;
75
78
  }
76
79
 
@@ -135,6 +138,7 @@ const authenticate = async (input, opts) => {
135
138
  } = await evaluateDmarc({
136
139
  headerFrom: dkimResult.headerFrom,
137
140
  fromFields: dkimResult.fromFields,
141
+ fromSyntax: dkimResult.fromSyntax,
138
142
  spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []),
139
143
  dkimDomains: (dkimResult.results || [])
140
144
  .filter(r => r.status.result === 'pass')
@@ -210,14 +214,13 @@ const authenticate = async (input, opts) => {
210
214
  let sealResult = await createSeal(false, {
211
215
  headers: dkimResult.headers,
212
216
  arc: dkimResult.arc,
213
- seal: Object.assign(
214
- {
215
- cv: arcResult.status.result,
216
- authResults: arcResult.authResults,
217
- signTime: new Date()
218
- },
219
- dkimResult.seal
220
- ),
217
+ // the chain status, the AAR and the instance always come from this message, never
218
+ // from the caller's seal options (RFC 8617 section 5.1 step 4)
219
+ seal: Object.assign({ signTime: new Date() }, dkimResult.seal, {
220
+ cv: arcResult.status.result,
221
+ authResults: arcResult.authResults,
222
+ i: undefined
223
+ }),
221
224
  strict: opts.strict
222
225
  });
223
226
 
package/lib/mta-sts.js CHANGED
@@ -633,11 +633,45 @@ const getPolicy = async (domain, knownPolicy, opts) => {
633
633
  return formatResult(Object.assign({}, knownPolicy), 'renewed', warnings);
634
634
  }
635
635
 
636
+ // The cached policy that stays in use when the policy for the new ID can not be fetched. A valid
637
+ // cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is replaced with
638
+ // a retry placeholder for the new policy ID instead of being reused
639
+ const canUseCached = () => {
640
+ if (cacheValid && knownPolicy.mode !== 'none') {
641
+ return true;
642
+ }
643
+ if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
644
+ // lax mode keeps applying an expired policy instead of falling back to no policy
645
+ warnings.push('expired-cache');
646
+ return true;
647
+ }
648
+ return false;
649
+ };
650
+
651
+ // RFC 8461 3.3: after a failed fetch, further attempts for the same policy ID are limited. The
652
+ // cached policy that is returned instead records the ID and when it can be fetched again
653
+ const useCachedAfterFailure = err =>
654
+ formatResult(
655
+ Object.assign({}, knownPolicy, {
656
+ error: err,
657
+ retryId: policyId,
658
+ retryAfter: new Date(Date.now() + ERROR_RETRY_DELAY).toISOString()
659
+ }),
660
+ 'errored',
661
+ warnings
662
+ );
663
+
664
+ let retryAfter = knownPolicy?.retryId === policyId ? new Date(knownPolicy.retryAfter).getTime() : NaN;
665
+ if (retryAfter > Date.now() && canUseCached()) {
666
+ // fetching this policy ID failed recently, keep using the cached policy without a new attempt
667
+ return formatResult(Object.assign({}, knownPolicy), 'errored', warnings);
668
+ }
669
+
636
670
  try {
637
671
  let policy = await fetchPolicyFile(domain, opts, warnings);
638
672
  if (!policy) {
639
673
  if (cacheValid) {
640
- return useCached(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
674
+ return useCachedAfterFailure(createError(`Policy host mta-sts.${domain} has no address records`, 'policy_host_not_found'));
641
675
  }
642
676
  return formatResult({ id: false, mode: 'none' }, 'not_found', warnings);
643
677
  }
@@ -650,17 +684,9 @@ const getPolicy = async (domain, knownPolicy, opts) => {
650
684
  warnings
651
685
  );
652
686
  } catch (err) {
653
- // A valid cached "none" policy (or an earlier retry placeholder) enforces nothing, so it is
654
- // replaced with a retry placeholder for the new policy ID below instead of being reused
655
- if (cacheValid && knownPolicy.mode !== 'none') {
687
+ if (canUseCached()) {
656
688
  // re-use existing policy on error
657
- return useCached(err);
658
- }
659
-
660
- if (!strict && knownPolicy?.id && ['enforce', 'testing'].includes(knownPolicy.mode)) {
661
- // lax mode keeps applying an expired policy instead of falling back to no policy
662
- warnings.push('expired-cache');
663
- return useCached(err);
689
+ return useCachedAfterFailure(err);
664
690
  }
665
691
 
666
692
  // continue as if the domain had no policy, and do not retry fetching this policy ID for a while