@paywalls-net/filter 1.3.13 → 1.3.14

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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Client SDK for integrating paywalls.net bot filtering and authorization services into your server or CDN.",
4
4
  "author": "paywalls.net",
5
5
  "license": "MIT",
6
- "version": "1.3.13",
6
+ "version": "1.3.14",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
package/src/index.js CHANGED
@@ -185,7 +185,18 @@ async function proxyVAIRequest(cfg, request) {
185
185
  cf.country ? `co=${cf.country}, re=${cf.region || ''}, ci=${cf.city || ''}, asn=${cf.asn || ''}` : null);
186
186
 
187
187
  // Tier 3: UA features + HMAC (§6.3)
188
- setIfPresent(forwardHeaders, 'X-PW-UA', extractUAFeatures(headers['user-agent']));
188
+ let uaFeatures = extractUAFeatures(headers['user-agent']);
189
+
190
+ // Sec-Fetch context mismatch detection (paywalls-site-dz23):
191
+ // vai.json is fetched via sync XHR — browsers set Sec-Fetch-Dest: empty,
192
+ // Mode: cors. If we see document/navigate, the headless browser is leaking
193
+ // page-level Sec-Fetch onto the XHR. Emit marker so cloud-api can classify.
194
+ if (uaFeatures && cloudApiPath.endsWith('/vai.json') &&
195
+ headers['sec-fetch-dest'] === 'document' && headers['sec-fetch-mode'] === 'navigate') {
196
+ uaFeatures += ', sec-fetch-mismatch';
197
+ }
198
+
199
+ setIfPresent(forwardHeaders, 'X-PW-UA', uaFeatures);
189
200
  setIfPresent(forwardHeaders, 'X-PW-UA-HMAC', await computeUAHMAC(headers['user-agent'], cfg.vaiUAHmacKey));
190
201
  setIfPresent(forwardHeaders, 'X-PW-CT-FP', await computeConfidenceToken(headers['user-agent'], headers['accept-language'], headers['sec-ch-ua']));
191
202
 
@@ -514,6 +514,11 @@ export function extractUAFeatures(userAgent) {
514
514
  if (AUTOMATION_MARKERS.some(re => re.test(ua))) parts.push('automation');
515
515
  if (isFabricatedVersion(ua)) parts.push('fabricated');
516
516
 
517
+ // Stale version: Chrome family with ver=0-79 is 6+ years old (pre-2020)
518
+ if (family === 'chrome' && extractMajorVersion(ua) !== null && extractMajorVersion(ua) < 80) {
519
+ parts.push('stale');
520
+ }
521
+
517
522
  parts.push(`entropy=${computeUAEntropy(ua)}`);
518
523
 
519
524
  return parts.join(', ');
@@ -200,5 +200,64 @@ describe('Legitimate browser UAs — correct feature extraction', () => {
200
200
  expect(result).toMatch(/ver=120-139/);
201
201
  expect(result).not.toMatch(/headless/);
202
202
  expect(result).not.toMatch(/fabricated/);
203
+ expect(result).not.toMatch(/stale/);
204
+ });
205
+ });
206
+
207
+ // ── 7. Stale browser version marker ───────────────────────────────────────
208
+
209
+ describe('Stale browser version — signal extraction', () => {
210
+ test('Chrome/59 (2017, ver=0-79) should have stale marker', () => {
211
+ const result = extractUAFeatures(
212
+ 'Mozilla/5.0 (Linux; Android 7.0; SM-G930V Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.125 Mobile Safari/537.36'
213
+ );
214
+ expect(result).toMatch(/ver=0-79/);
215
+ expect(result).toMatch(/\bstale\b/);
216
+ });
217
+
218
+ test('Chrome/79 (2019, ver=0-79) should have stale marker', () => {
219
+ const result = extractUAFeatures(
220
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.79 Safari/537.36'
221
+ );
222
+ expect(result).toMatch(/ver=0-79/);
223
+ expect(result).toMatch(/\bstale\b/);
224
+ });
225
+
226
+ test('Chrome/83 (80-99 bucket, borderline old) should NOT have stale marker', () => {
227
+ const result = extractUAFeatures(
228
+ 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36'
229
+ );
230
+ expect(result).toMatch(/ver=80-99/);
231
+ expect(result).not.toMatch(/\bstale\b/);
232
+ });
233
+
234
+ test('Chrome/145 (current) should NOT have stale marker', () => {
235
+ const result = extractUAFeatures(
236
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'
237
+ );
238
+ expect(result).toMatch(/ver=140-159/);
239
+ expect(result).not.toMatch(/\bstale\b/);
240
+ });
241
+
242
+ test('Safari/17 should NOT have stale marker', () => {
243
+ const result = extractUAFeatures(
244
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15'
245
+ );
246
+ expect(result).not.toMatch(/\bstale\b/);
247
+ });
248
+
249
+ test('Firefox/130 should NOT have stale marker', () => {
250
+ const result = extractUAFeatures(
251
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0'
252
+ );
253
+ expect(result).not.toMatch(/\bstale\b/);
254
+ });
255
+
256
+ test('Fabricated Chrome/48.0.1025.1402 also gets stale (both markers)', () => {
257
+ const result = extractUAFeatures(
258
+ 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.1025.1402 Mobile Safari/537.36'
259
+ );
260
+ expect(result).toMatch(/\bfabricated\b/);
261
+ expect(result).toMatch(/\bstale\b/);
203
262
  });
204
263
  });
@@ -291,6 +291,56 @@ describe('proxyVAIRequest — signal extraction pipeline', () => {
291
291
  });
292
292
  });
293
293
 
294
+ // ── sec-fetch-mismatch CDN marker emission ──────────────────────────────────
295
+ //
296
+ // When the CDN sees sec-fetch-dest: document + sec-fetch-mode: navigate on a
297
+ // vai.json request, it appends sec-fetch-mismatch to X-PW-UA. This marker is
298
+ // used by the cloud-api to reclassify the request as OTHER without inspecting
299
+ // raw Sec-Fetch headers in the classification path.
300
+
301
+ describe('CDN sec-fetch-mismatch marker emission', () => {
302
+ let handler;
303
+
304
+ beforeAll(async () => {
305
+ handler = await init('cloudflare');
306
+ });
307
+
308
+ async function proxyVAI(url, headerMap, cf) {
309
+ const request = makeRequest(url, headerMap, cf);
310
+ capturedFetchArgs = null;
311
+ await handler(request, ENV, CTX);
312
+ return capturedFetchArgs;
313
+ }
314
+
315
+ test('document/navigate on vai.json → appends sec-fetch-mismatch', async () => {
316
+ const mismatchHeaders = {
317
+ ...CHROME_MAC_HEADERS,
318
+ 'sec-fetch-dest': 'document',
319
+ 'sec-fetch-mode': 'navigate',
320
+ };
321
+ const args = await proxyVAI('https://pub.example.com/pw/vai.json', mismatchHeaders, CF_PROPS);
322
+ expect(args.headers['X-PW-UA']).toContain('sec-fetch-mismatch');
323
+ });
324
+
325
+ test('empty/cors on vai.json (normal XHR) → no sec-fetch-mismatch', async () => {
326
+ const args = await proxyVAI('https://pub.example.com/pw/vai.json', CHROME_MAC_HEADERS, CF_PROPS);
327
+ expect(args.headers['X-PW-UA']).not.toContain('sec-fetch-mismatch');
328
+ });
329
+
330
+ test('document/navigate on non-vai path → no sec-fetch-mismatch', async () => {
331
+ const mismatchHeaders = {
332
+ ...CHROME_MAC_HEADERS,
333
+ 'sec-fetch-dest': 'document',
334
+ 'sec-fetch-mode': 'navigate',
335
+ };
336
+ const args = await proxyVAI('https://pub.example.com/pw/access/check', mismatchHeaders, CF_PROPS);
337
+ // sec-fetch-mismatch only applies to vai.json requests
338
+ if (args && args.headers) {
339
+ expect(args.headers['X-PW-UA'] || '').not.toContain('sec-fetch-mismatch');
340
+ }
341
+ });
342
+ });
343
+
294
344
  // ── logAccess — non-VAI path raw header forwarding ───────────────────────────
295
345
  //
296
346
  // Verifies that logAccess() does NOT transform headers like proxyVAIRequest().