dns-worker 1.1.0 → 1.1.1-canary.e00a6a3

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img src="https://raw.githubusercontent.com/Obein/DNS-Worker/main/web/src/assets/obex_cat_eye_logo-256.webp" alt="DNS Worker Logo" width="128">
3
3
  <h1>DNS Worker</h1>
4
4
  <p>Privacy-First Protective DNS Resolver & DoH / DoT Server</p>
5
- <p>Protect your first hop on the internet · Dual-Engine: Cloudflare Workers Edge or Standalone Server (VPS / Linux / macOS / Windows)</p>
5
+ <p>Protect your first hop on the internet</p>
6
6
  <p align="center">
7
7
  English | <a href="README_zh-CN.md">中文 (简体)</a> | <a href="README_zh-TW.md">中文 (正體)</a>
8
8
  </p>
@@ -51,23 +51,6 @@
51
51
 
52
52
  ---
53
53
 
54
- ## 🔐 Advanced Privacy: Local-First & Post-Quantum E2EE
55
-
56
- DNS Worker redefines personal DNS observability by combining local-first browser computation with cutting-edge post-quantum cryptography:
57
-
58
- ### ⚡ Local-First Browser SQLite (OPFS + WASM)
59
- * **Instantaneous 0ms Queries**: Resolution logs and analytical charts render immediately from an in-browser SQLite database powered by Origin Private File System (OPFS) and Dedicated Web Workers.
60
- * **Quota Preservation & Offline Analytics**: High-frequency filtering, pagination, and multi-dimensional analytics run locally without issuing remote D1 read queries, drastically reducing Cloudflare D1 quota consumption and enabling full offline inspection.
61
- * **Smart Bidirectional Sync**: Automatically reconciles local storage with remote D1 in the background with zero UI freeze.
62
-
63
- ### 🛡️ Post-Quantum End-to-End Encryption (P256-MLKEM768)
64
- * **Zero-Knowledge Cloud Storage**: Sensitive log fields (domains, client IPs, answers, geo locations, and upstream servers) are encrypted before reaching persistent cloud storage. Cloudflare Workers and D1 database store only ciphertexts.
65
- * **Quantum-Resistant Hybrid Lattice KEM**: Adopts the NIST FIPS 203 standardized **P256-MLKEM768** (ML-KEM-768 + ECDH P-256) hybrid algorithm, defending user query logs against future "Harvest Now, Decrypt Later" quantum attacks.
66
- * **Decoupled Hourly Envelope Encryption**: Automatically encapsulates and provisions hourly Data Encryption Keys (DEK), compressing KEM database overhead by ~80% while sustaining hot-path pipeline encryption throughput of **20,000+ queries/second**.
67
- * **Hardware Passkey Protection**: The private key seed is wrapped with hardware Passkeys (WebAuthn PRF) and single-use self-rotating Recovery Keys; no plaintext secret ever touches the server.
68
-
69
- ---
70
-
71
54
  ## 🖼️ Quick Look
72
55
 
73
56
  | User Login |
@@ -1347,11 +1347,13 @@ function decodeName(buffer, offset) {
1347
1347
  let jumped = false;
1348
1348
  let consumed = 0;
1349
1349
  let iterations = 0;
1350
+ let terminated = false;
1350
1351
  while (iterations < 128) {
1351
1352
  if (curr >= buffer.length) break;
1352
1353
  const len = buffer[curr];
1353
1354
  if (len === 0) {
1354
1355
  if (!jumped) consumed++;
1356
+ terminated = true;
1355
1357
  break;
1356
1358
  }
1357
1359
  if ((len & 192) === 192) {
@@ -1365,6 +1367,9 @@ function decodeName(buffer, offset) {
1365
1367
  iterations++;
1366
1368
  continue;
1367
1369
  }
1370
+ if ((len & 192) !== 0) {
1371
+ break;
1372
+ }
1368
1373
  if (name.length > 0) name += ".";
1369
1374
  if (curr + 1 + len > buffer.length) break;
1370
1375
  for (let i = 0; i < len; i++) {
@@ -1374,6 +1379,9 @@ function decodeName(buffer, offset) {
1374
1379
  curr += len + 1;
1375
1380
  iterations++;
1376
1381
  }
1382
+ if (!terminated) {
1383
+ return { name: "", read: 0 };
1384
+ }
1377
1385
  return { name, read: consumed };
1378
1386
  }
1379
1387
  function getQTypeName(type) {
@@ -1383,6 +1391,7 @@ function parseDNSQueryFromRaw(raw) {
1383
1391
  try {
1384
1392
  if (raw.length < 16) return null;
1385
1393
  const { name, read } = decodeName(raw, 12);
1394
+ if (read === 0) return null;
1386
1395
  const qtypeOffset = 12 + read;
1387
1396
  if (qtypeOffset + 4 > raw.length) return null;
1388
1397
  const qtypeCode = raw[qtypeOffset] << 8 | raw[qtypeOffset + 1];
@@ -1432,12 +1441,14 @@ function parseDNSAnswer(raw) {
1432
1441
  for (let i = 0; i < qCount; i++) {
1433
1442
  if (offset >= raw.length) return [];
1434
1443
  const { read } = decodeName(raw, offset);
1444
+ if (read === 0) return [];
1435
1445
  offset += read + 4;
1436
1446
  if (offset > raw.length) return [];
1437
1447
  }
1438
1448
  for (let i = 0; i < ansCount; i++) {
1439
1449
  if (offset >= raw.length) break;
1440
1450
  const { name, read: nameRead } = decodeName(raw, offset);
1451
+ if (nameRead === 0) break;
1441
1452
  offset += nameRead;
1442
1453
  if (offset + 10 > raw.length) {
1443
1454
  break;
@@ -1609,18 +1620,21 @@ function rebuildEchWithFronting(rawBase64, newFrontingDomain = DEFAULT_ECH_FRONT
1609
1620
  const pkLen = bytes[9] << 8 | bytes[10];
1610
1621
  const pkStart = 11;
1611
1622
  const pkEnd = pkStart + pkLen;
1612
- if (pkEnd > bytes.length) return rawBase64;
1623
+ if (pkEnd + 4 > bytes.length) return rawBase64;
1613
1624
  const publicKey = bytes.slice(pkStart, pkEnd);
1614
1625
  let offset = pkEnd;
1615
1626
  const csLen = bytes[offset] << 8 | bytes[offset + 1];
1616
1627
  offset += 2;
1628
+ if (offset + csLen + 2 > bytes.length) return rawBase64;
1617
1629
  const cipherSuites = bytes.slice(offset, offset + csLen);
1618
1630
  offset += csLen;
1619
1631
  const maxNameLen = bytes[offset++];
1620
1632
  const oldNameLen = bytes[offset++];
1633
+ if (offset + oldNameLen + 2 > bytes.length) return rawBase64;
1621
1634
  offset += oldNameLen;
1622
1635
  const extLen = bytes[offset] << 8 | bytes[offset + 1];
1623
1636
  offset += 2;
1637
+ if (offset + extLen > bytes.length) return rawBase64;
1624
1638
  const extensions = bytes.slice(offset, offset + extLen);
1625
1639
  const targetName = (newFrontingDomain || DEFAULT_ECH_FRONTING_DOMAIN).trim().toLowerCase();
1626
1640
  const nameBytes = new Uint8Array(targetName.length);
@@ -2098,21 +2112,6 @@ var SystemSettingsModel = class {
2098
2112
  // src/utils/ech/sync.ts
2099
2113
  var cachedActiveCfEchConfig = null;
2100
2114
  var loadPromise = null;
2101
- function setActiveCfEchConfig(echBase64) {
2102
- if (echBase64 && echBase64 !== cachedActiveCfEchConfig) {
2103
- cachedActiveCfEchConfig = echBase64;
2104
- }
2105
- }
2106
- async function saveActiveCfEchConfig(db, echBase64) {
2107
- if (!echBase64) return;
2108
- setActiveCfEchConfig(echBase64);
2109
- try {
2110
- const settingsModel = new SystemSettingsModel(db);
2111
- await settingsModel.set("cf_active_ech_config", echBase64);
2112
- } catch (e) {
2113
- console.warn("[ECH] Could not persist active ECH config to DB:", e);
2114
- }
2115
- }
2116
2115
  async function ensureCloudflareIpRangesLoaded(db) {
2117
2116
  const currentCidrs = getActiveCfProxyCidrs();
2118
2117
  if (currentCidrs.length > 0) {
@@ -2310,13 +2309,19 @@ function buildDNSQuery(name, type) {
2310
2309
  raw.set(new Uint8Array(question), header.length);
2311
2310
  return raw;
2312
2311
  }
2312
+ function createServFail(queryRaw) {
2313
+ const res = new Uint8Array(12);
2314
+ if (queryRaw && queryRaw.length >= 12) {
2315
+ res.set(queryRaw.slice(0, 12));
2316
+ }
2317
+ res[2] = res[2] & 1 | 128;
2318
+ res[3] = res[3] & 240 | 2;
2319
+ return res;
2320
+ }
2313
2321
  function buildResponseMulti(queryRaw, records, rcode = 0) {
2314
2322
  try {
2315
2323
  if (!queryRaw || queryRaw.length < 12) {
2316
- const err = new Uint8Array(12);
2317
- err[2] = 129;
2318
- err[3] = 130;
2319
- return err;
2324
+ return createServFail(queryRaw);
2320
2325
  }
2321
2326
  const header = new Uint8Array(12);
2322
2327
  header.set(queryRaw.slice(0, 12));
@@ -2326,14 +2331,12 @@ function buildResponseMulti(queryRaw, records, rcode = 0) {
2326
2331
  const qCount = queryRaw[4] << 8 | queryRaw[5];
2327
2332
  for (let i = 0; i < qCount; i++) {
2328
2333
  const { read } = decodeName(queryRaw, qEnd);
2329
- if (read === 0 && qEnd < queryRaw.length) {
2330
- qEnd++;
2331
- } else {
2332
- qEnd += read + 4;
2334
+ if (read === 0) {
2335
+ return createServFail(queryRaw);
2333
2336
  }
2337
+ qEnd += read + 4;
2334
2338
  if (qEnd > queryRaw.length) {
2335
- qEnd = queryRaw.length;
2336
- break;
2339
+ return createServFail(queryRaw);
2337
2340
  }
2338
2341
  }
2339
2342
  const questionSection = queryRaw.slice(12, qEnd);
@@ -2408,10 +2411,7 @@ function buildResponseMulti(queryRaw, records, rcode = 0) {
2408
2411
  function buildResponse(queryRaw, type, value, ttl = 60, rcode = 0) {
2409
2412
  try {
2410
2413
  if (!queryRaw || queryRaw.length < 12) {
2411
- const err = new Uint8Array(12);
2412
- err[2] = 129;
2413
- err[3] = 130;
2414
- return err;
2414
+ return createServFail(queryRaw);
2415
2415
  }
2416
2416
  const header = new Uint8Array(12);
2417
2417
  header.set(queryRaw.slice(0, 12));
@@ -2421,14 +2421,12 @@ function buildResponse(queryRaw, type, value, ttl = 60, rcode = 0) {
2421
2421
  const qCount = queryRaw[4] << 8 | queryRaw[5];
2422
2422
  for (let i = 0; i < qCount; i++) {
2423
2423
  const { read } = decodeName(queryRaw, qEnd);
2424
- if (read === 0 && qEnd < queryRaw.length) {
2425
- qEnd++;
2426
- } else {
2427
- qEnd += read + 4;
2424
+ if (read === 0) {
2425
+ return createServFail(queryRaw);
2428
2426
  }
2427
+ qEnd += read + 4;
2429
2428
  if (qEnd > queryRaw.length) {
2430
- qEnd = queryRaw.length;
2431
- break;
2429
+ return createServFail(queryRaw);
2432
2430
  }
2433
2431
  }
2434
2432
  const questionSection = queryRaw.slice(12, qEnd);
@@ -3051,18 +3049,31 @@ var BloomFilter = class _BloomFilter {
3051
3049
  * 从原始二进制流恢复 (零拷贝/对齐安全反序列化)
3052
3050
  */
3053
3051
  static fromUint8Array(buffer) {
3052
+ if (!buffer || buffer.byteLength < 8) {
3053
+ throw new Error("Invalid bloom filter buffer: header too short");
3054
+ }
3054
3055
  const view = new DataView(buffer.buffer, buffer.byteOffset, 8);
3055
3056
  const size = view.getUint32(0, true);
3056
3057
  const hashes = view.getUint32(4, true);
3058
+ if (size === 0 || hashes === 0) {
3059
+ throw new Error("Invalid bloom filter parameters: size and hashes must be positive integers");
3060
+ }
3057
3061
  const offset = buffer.byteOffset + 8;
3058
3062
  const length = buffer.byteLength - 8;
3063
+ const expectedWords = size + 31 >>> 5;
3064
+ const actualWords = length >> 2;
3065
+ if (actualWords < expectedWords) {
3066
+ throw new Error(
3067
+ `Invalid bloom filter buffer: bit array truncated (expected ${expectedWords} words, got ${actualWords})`
3068
+ );
3069
+ }
3059
3070
  let bitData;
3060
3071
  if (offset % 4 === 0) {
3061
- bitData = new Uint32Array(buffer.buffer, offset, length >> 2);
3072
+ bitData = new Uint32Array(buffer.buffer, offset, expectedWords);
3062
3073
  } else {
3063
- const copy = new Uint8Array(length);
3064
- copy.set(buffer.subarray(8));
3065
- bitData = new Uint32Array(copy.buffer, 0, length >> 2);
3074
+ const copy = new Uint8Array(expectedWords << 2);
3075
+ copy.set(buffer.subarray(8, 8 + (expectedWords << 2)));
3076
+ bitData = new Uint32Array(copy.buffer, 0, expectedWords);
3066
3077
  }
3067
3078
  return new _BloomFilter(size, hashes, bitData);
3068
3079
  }
@@ -3522,9 +3533,29 @@ var FORBIDDEN_HOSTNAMES = [
3522
3533
  "localhost",
3523
3534
  "metadata.google.internal",
3524
3535
  // GCP
3525
- "169.254.169.254"
3536
+ "metadata.goog",
3537
+ "169.254.169.254",
3526
3538
  // AWS/GCP/Azure IMDS
3539
+ "instance-data",
3540
+ "localtest.me"
3541
+ ];
3542
+ var FORBIDDEN_HOSTNAME_SUFFIXES = [
3543
+ ".localhost",
3544
+ ".local",
3545
+ ".internal",
3546
+ ".lan",
3547
+ ".home.arpa",
3548
+ ".corp",
3549
+ ".home",
3550
+ ".localtest.me"
3527
3551
  ];
3552
+ function extractEmbeddedDnsIp(hostname) {
3553
+ const dotMatch = hostname.match(/(?:^|\.)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\.(?:nip\.io|sslip\.io)$/i);
3554
+ if (dotMatch) return dotMatch[1];
3555
+ const dashMatch = hostname.match(/(?:^|\.)(\d{1,3})-(\d{1,3})-(\d{1,3})-(\d{1,3})\.(?:nip\.io|sslip\.io)$/i);
3556
+ if (dashMatch) return `${dashMatch[1]}.${dashMatch[2]}.${dashMatch[3]}.${dashMatch[4]}`;
3557
+ return null;
3558
+ }
3528
3559
  var PRIVATE_AND_RESERVED_CIDRS = [
3529
3560
  // IPv4 Special/Private Ranges
3530
3561
  "0.0.0.0/8",
@@ -3622,6 +3653,20 @@ function isSafeUrl(urlString) {
3622
3653
  if (FORBIDDEN_HOSTNAMES.includes(rawHostname) || FORBIDDEN_HOSTNAMES.includes(url.hostname.toLowerCase())) {
3623
3654
  return false;
3624
3655
  }
3656
+ for (const suffix of FORBIDDEN_HOSTNAME_SUFFIXES) {
3657
+ if (rawHostname.endsWith(suffix) || rawHostname === suffix.slice(1)) {
3658
+ return false;
3659
+ }
3660
+ }
3661
+ if (rawHostname.includes("metadata.google")) {
3662
+ return false;
3663
+ }
3664
+ const embeddedIp = extractEmbeddedDnsIp(rawHostname);
3665
+ if (embeddedIp) {
3666
+ if (!isPublicInternetIP(embeddedIp)) {
3667
+ return false;
3668
+ }
3669
+ }
3625
3670
  if ((isIPv4(rawHostname) || isIPv6(rawHostname)) && !isPublicInternetIP(rawHostname)) {
3626
3671
  return false;
3627
3672
  }
@@ -4005,7 +4050,6 @@ async function processBestEffortEch(query, initialParsedAnswers, initialAnswer,
4005
4050
  const echMatch = a.data.match(/ech=([A-Za-z0-9+/=]+)/);
4006
4051
  if (echMatch) {
4007
4052
  existingEch = echMatch[1];
4008
- context.ctx.waitUntil(saveActiveCfEchConfig(context.env.DB, existingEch));
4009
4053
  }
4010
4054
  }
4011
4055
  let isCf = existingIpv4s.some(isCloudflareIp) || existingIpv6s.some(isCloudflareIp);
@@ -10693,7 +10737,8 @@ var UdpDnsServer = class {
10693
10737
  env,
10694
10738
  ctx
10695
10739
  };
10696
- const request = new Request(`http://${rinfo.address}/dns-query`, {
10740
+ const host = rinfo.address.includes(":") ? `[${rinfo.address}]` : rinfo.address;
10741
+ const request = new Request(`http://${host}/dns-query`, {
10697
10742
  headers: {
10698
10743
  "CF-Connecting-IP": rinfo.address,
10699
10744
  "Accept": "application/dns-message",
@@ -10835,12 +10880,20 @@ Skipping DoT server.`);
10835
10880
  candidateKey = firstPart;
10836
10881
  }
10837
10882
  }
10883
+ socket.setTimeout(3e4, () => {
10884
+ socket.destroy();
10885
+ });
10838
10886
  socket.on("error", (err) => {
10839
10887
  if (err.code !== "ECONNRESET") {
10840
10888
  console.warn(`[DoT] Socket error from ${remoteIp}:`, err.message);
10841
10889
  }
10842
10890
  });
10891
+ const MAX_DOT_BUFFER = 65537;
10843
10892
  socket.on("data", async (chunk) => {
10893
+ if (buffer.length + chunk.length > MAX_DOT_BUFFER) {
10894
+ socket.destroy();
10895
+ return;
10896
+ }
10844
10897
  buffer = Buffer2.concat([buffer, chunk]);
10845
10898
  while (buffer.length >= 2) {
10846
10899
  const msgLen = buffer.readUInt16BE(0);
@@ -10884,7 +10937,8 @@ Skipping DoT server.`);
10884
10937
  env,
10885
10938
  ctx
10886
10939
  };
10887
- const request = new Request(`http://${remoteIp}/dns-query`, {
10940
+ const host = remoteIp.includes(":") ? `[${remoteIp}]` : remoteIp;
10941
+ const request = new Request(`http://${host}/dns-query`, {
10888
10942
  headers: {
10889
10943
  "CF-Connecting-IP": remoteIp,
10890
10944
  "Accept": "application/dns-message",
@@ -11235,7 +11289,8 @@ async function verifyTurnstile(token, secret, ip) {
11235
11289
  body: params,
11236
11290
  headers: {
11237
11291
  "Content-Type": "application/x-www-form-urlencoded"
11238
- }
11292
+ },
11293
+ signal: AbortSignal.timeout(5e3)
11239
11294
  });
11240
11295
  const outcome = await result.json();
11241
11296
  return !!outcome.success;
@@ -11721,20 +11776,15 @@ async function verifyRegistrationResponse(options) {
11721
11776
  }
11722
11777
  const clientOriginUrl = new URL(clientData.origin);
11723
11778
  const expectedOriginUrl = expectedOrigin.includes("://") ? new URL(expectedOrigin) : new URL(`https://${expectedOrigin}`);
11724
- if (clientOriginUrl.hostname.toLowerCase() !== expectedOriginUrl.hostname.toLowerCase()) {
11779
+ if (clientOriginUrl.origin.toLowerCase() !== expectedOriginUrl.origin.toLowerCase()) {
11725
11780
  throw new Error(
11726
- `WebAuthn origin mismatch: expected ${expectedOriginUrl.hostname}, received ${clientOriginUrl.hostname}`
11781
+ `WebAuthn origin mismatch: expected ${expectedOriginUrl.origin}, received ${clientOriginUrl.origin}`
11727
11782
  );
11728
11783
  }
11729
11784
  const isLocalhost = clientOriginUrl.hostname === "localhost" || clientOriginUrl.hostname === "127.0.0.1";
11730
11785
  if (!isLocalhost && clientOriginUrl.protocol !== "https:") {
11731
11786
  throw new Error(`WebAuthn requires HTTPS origin, received: ${clientOriginUrl.protocol}`);
11732
11787
  }
11733
- if (clientOriginUrl.protocol !== expectedOriginUrl.protocol) {
11734
- throw new Error(
11735
- `WebAuthn protocol mismatch: expected ${expectedOriginUrl.protocol}, received ${clientOriginUrl.protocol}`
11736
- );
11737
- }
11738
11788
  const attestationBytes = base64UrlDecode(attestationObject);
11739
11789
  const { value: attestation } = decodeCbor(attestationBytes);
11740
11790
  if (!attestation.authData) {
@@ -11778,20 +11828,15 @@ async function verifyAuthenticationResponse(options) {
11778
11828
  }
11779
11829
  const clientOriginUrl = new URL(clientData.origin);
11780
11830
  const expectedOriginUrl = expectedOrigin.includes("://") ? new URL(expectedOrigin) : new URL(`https://${expectedOrigin}`);
11781
- if (clientOriginUrl.hostname.toLowerCase() !== expectedOriginUrl.hostname.toLowerCase()) {
11831
+ if (clientOriginUrl.origin.toLowerCase() !== expectedOriginUrl.origin.toLowerCase()) {
11782
11832
  throw new Error(
11783
- `WebAuthn origin mismatch: expected ${expectedOriginUrl.hostname}, received ${clientOriginUrl.hostname}`
11833
+ `WebAuthn origin mismatch: expected ${expectedOriginUrl.origin}, received ${clientOriginUrl.origin}`
11784
11834
  );
11785
11835
  }
11786
11836
  const isLocalhost = clientOriginUrl.hostname === "localhost" || clientOriginUrl.hostname === "127.0.0.1";
11787
11837
  if (!isLocalhost && clientOriginUrl.protocol !== "https:") {
11788
11838
  throw new Error(`WebAuthn requires HTTPS origin, received: ${clientOriginUrl.protocol}`);
11789
11839
  }
11790
- if (clientOriginUrl.protocol !== expectedOriginUrl.protocol) {
11791
- throw new Error(
11792
- `WebAuthn protocol mismatch: expected ${expectedOriginUrl.protocol}, received ${clientOriginUrl.protocol}`
11793
- );
11794
- }
11795
11840
  const authDataBytes = base64UrlDecode(authenticatorData);
11796
11841
  if (authDataBytes.length < 37) {
11797
11842
  throw new Error("Invalid authenticatorData length");
@@ -12052,7 +12097,9 @@ async function handleLoginRequest(request, env) {
12052
12097
  } else if ((hasTotp || hasPasskeys || hasRecoveryKeys) && recoveryKey) {
12053
12098
  let storedHashes = [];
12054
12099
  try {
12055
- storedHashes = JSON.parse(user.totp_recovery_keys || "[]");
12100
+ const raw = user.totp_recovery_keys;
12101
+ storedHashes = typeof raw === "string" ? JSON.parse(raw || "[]") : raw || [];
12102
+ if (!Array.isArray(storedHashes)) storedHashes = [storedHashes];
12056
12103
  } catch {
12057
12104
  }
12058
12105
  const matchIndex = await findMatchingRecoveryKey(recoveryKey, storedHashes);
@@ -12444,14 +12491,18 @@ async function handleForgotPasswordRequest(request, env) {
12444
12491
  const passkeyModel = new PasskeyModel(env.DB);
12445
12492
  const passkeys = await passkeyModel.listByUser(user.id);
12446
12493
  const has_passkey = passkeys.length > 0;
12447
- const has_totp = !!user.totp_enabled && !!user.totp_secret;
12494
+ const has_totp = !!user.totp_enabled && (!!user.totp_secret || !!user.totp_secret_encrypted);
12448
12495
  let has_recovery_keys = false;
12449
- if (user.totp_recovery_keys) {
12496
+ const rawRecoveryKeys = user.totp_recovery_keys;
12497
+ if (rawRecoveryKeys) {
12450
12498
  try {
12451
- const parsed = JSON.parse(user.totp_recovery_keys);
12499
+ const parsed = typeof rawRecoveryKeys === "string" ? JSON.parse(rawRecoveryKeys) : rawRecoveryKeys;
12452
12500
  has_recovery_keys = Array.isArray(parsed) && parsed.length > 0;
12453
12501
  } catch {
12502
+ has_recovery_keys = typeof rawRecoveryKeys === "string" && rawRecoveryKeys.length > 0;
12454
12503
  }
12504
+ } else if (user.totp_recovery_keys_encrypted) {
12505
+ has_recovery_keys = true;
12455
12506
  }
12456
12507
  if (!has_passkey && !has_totp && !has_recovery_keys) {
12457
12508
  return new Response(JSON.stringify({
@@ -12548,7 +12599,9 @@ async function handleForgotPasswordRequest(request, env) {
12548
12599
  } else if (recoveryKey) {
12549
12600
  let storedHashes = [];
12550
12601
  try {
12551
- storedHashes = JSON.parse(user.totp_recovery_keys || "[]");
12602
+ const raw = user.totp_recovery_keys;
12603
+ storedHashes = typeof raw === "string" ? JSON.parse(raw || "[]") : raw || [];
12604
+ if (!Array.isArray(storedHashes)) storedHashes = [storedHashes];
12552
12605
  } catch {
12553
12606
  }
12554
12607
  const matchIndex = await findMatchingRecoveryKey(recoveryKey, storedHashes);
@@ -12560,12 +12613,22 @@ async function handleForgotPasswordRequest(request, env) {
12560
12613
  await userModel.consumeRecoveryKey(user.id, matchIndex, storedHashes);
12561
12614
  await activityLog.record(user.id, "recovery_key_used", clientIp, userAgent, { flow: "forgot_password", remaining: storedHashes.length - 1 });
12562
12615
  } else if (totpTokenHash && user.totp_secret) {
12563
- const isValid = await verifyTOTP(user.totp_secret, totpTokenHash, totpSalt);
12564
- if (!isValid) {
12616
+ const totpResult = await verifyTOTPWithStep(user.totp_secret, totpTokenHash, totpSalt);
12617
+ if (!totpResult.valid) {
12565
12618
  state.failedAttempts++;
12566
12619
  await cacheUtils.set(cache, cacheKey, state, 300);
12567
12620
  return new Response("Invalid TOTP code", { status: 400 });
12568
12621
  }
12622
+ if (totpResult.timeStep !== void 0) {
12623
+ const stepKey = `totp_step:${user.id}:${totpResult.timeStep}`;
12624
+ const replayed = await cacheUtils.get(cache, stepKey);
12625
+ if (replayed) {
12626
+ state.failedAttempts++;
12627
+ await cacheUtils.set(cache, cacheKey, state, 300);
12628
+ return new Response("TOTP code already used, please wait for next code", { status: 400 });
12629
+ }
12630
+ await cacheUtils.set(cache, stepKey, true, 180);
12631
+ }
12569
12632
  await activityLog.record(user.id, "totp_verify_success", clientIp, userAgent, { flow: "forgot_password" });
12570
12633
  } else {
12571
12634
  return new Response("No valid MFA credential provided", { status: 400 });
@@ -13345,14 +13408,62 @@ function parseLine(line) {
13345
13408
 
13346
13409
  // src/utils/listFetcher.ts
13347
13410
  async function fetchListContent(url, maxBytes, timeoutMs, onDomain) {
13348
- if (!isSafeUrl(url)) {
13349
- return {
13350
- count: 0,
13351
- error: "Invalid list URL. Private networks and localhosts are not allowed."
13352
- };
13353
- }
13411
+ const deadline = Date.now() + timeoutMs;
13412
+ let currentUrl = url;
13413
+ let redirectCount = 0;
13414
+ const MAX_REDIRECTS = 5;
13415
+ let response;
13354
13416
  try {
13355
- const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
13417
+ while (true) {
13418
+ if (!isSafeUrl(currentUrl)) {
13419
+ return {
13420
+ count: 0,
13421
+ error: "Invalid list URL. Private networks, localhosts, and internal domains are not allowed."
13422
+ };
13423
+ }
13424
+ const remainingTimeout = deadline - Date.now();
13425
+ if (remainingTimeout <= 0) {
13426
+ return { count: 0, error: "Request timed out" };
13427
+ }
13428
+ response = await fetch(currentUrl, {
13429
+ redirect: "manual",
13430
+ signal: AbortSignal.timeout(remainingTimeout)
13431
+ });
13432
+ if (response.status >= 300 && response.status < 400) {
13433
+ const location = response.headers.get("location");
13434
+ if (!location) {
13435
+ return {
13436
+ count: 0,
13437
+ error: `Redirect status ${response.status} without Location header`
13438
+ };
13439
+ }
13440
+ redirectCount++;
13441
+ if (redirectCount > MAX_REDIRECTS) {
13442
+ return {
13443
+ count: 0,
13444
+ error: `Too many redirects (max ${MAX_REDIRECTS})`
13445
+ };
13446
+ }
13447
+ try {
13448
+ const nextUrl = new URL(location, currentUrl).toString();
13449
+ const nextProto = new URL(nextUrl).protocol;
13450
+ if (nextProto !== "http:" && nextProto !== "https:") {
13451
+ return {
13452
+ count: 0,
13453
+ error: `Invalid redirect protocol: ${nextProto}`
13454
+ };
13455
+ }
13456
+ currentUrl = nextUrl;
13457
+ continue;
13458
+ } catch {
13459
+ return {
13460
+ count: 0,
13461
+ error: `Invalid redirect location: ${location}`
13462
+ };
13463
+ }
13464
+ }
13465
+ break;
13466
+ }
13356
13467
  if (!response.ok) {
13357
13468
  return {
13358
13469
  count: 0,
@@ -14836,23 +14947,22 @@ async function handlePasskeysRequest(request, env, user, pathParts, _ctx) {
14836
14947
  const dbUser = await userModel.getById(user.id);
14837
14948
  let recoveryKeys = [];
14838
14949
  if (dbUser) {
14950
+ let hasExistingRecoveryKeys = false;
14839
14951
  if (dbUser.totp_recovery_keys) {
14840
- let parsedKeys = null;
14841
14952
  try {
14842
- parsedKeys = typeof dbUser.totp_recovery_keys === "string" ? JSON.parse(dbUser.totp_recovery_keys) : dbUser.totp_recovery_keys;
14953
+ const parsed2 = typeof dbUser.totp_recovery_keys === "string" ? JSON.parse(dbUser.totp_recovery_keys) : dbUser.totp_recovery_keys;
14954
+ if (Array.isArray(parsed2) && parsed2.length > 0) {
14955
+ hasExistingRecoveryKeys = true;
14956
+ }
14843
14957
  } catch {
14844
- parsedKeys = [dbUser.totp_recovery_keys];
14845
- }
14846
- if (!Array.isArray(parsedKeys)) parsedKeys = [parsedKeys];
14847
- for (const item of parsedKeys) {
14848
- if (typeof item === "object" && item?.key) {
14849
- recoveryKeys.push(item.key);
14850
- } else if (typeof item === "string" && !/^[a-fA-F0-9]{64}$/.test(item.trim())) {
14851
- recoveryKeys.push(item);
14958
+ if (dbUser.totp_recovery_keys.length > 0) {
14959
+ hasExistingRecoveryKeys = true;
14852
14960
  }
14853
14961
  }
14962
+ } else if (dbUser.totp_recovery_keys_encrypted) {
14963
+ hasExistingRecoveryKeys = true;
14854
14964
  }
14855
- if (recoveryKeys.length === 0) {
14965
+ if (!hasExistingRecoveryKeys) {
14856
14966
  const plaintextKeys = generateRecoveryKeys();
14857
14967
  const storedItems = await Promise.all(
14858
14968
  plaintextKeys.map(async (k) => ({
@@ -14864,10 +14974,16 @@ async function handlePasskeysRequest(request, env, user, pathParts, _ctx) {
14864
14974
  }
14865
14975
  }
14866
14976
  await activityLog.record(user.id, "passkey_registered", clientIp, userAgent, { name: passkeyName }, sessionHash);
14867
- return new Response(JSON.stringify({ ...created, recovery_keys: recoveryKeys }), {
14868
- status: 201,
14869
- headers: { "Content-Type": "application/json" }
14870
- });
14977
+ return new Response(
14978
+ JSON.stringify({
14979
+ ...created,
14980
+ recovery_keys: recoveryKeys.length > 0 ? recoveryKeys : void 0
14981
+ }),
14982
+ {
14983
+ status: 201,
14984
+ headers: { "Content-Type": "application/json" }
14985
+ }
14986
+ );
14871
14987
  } catch (err) {
14872
14988
  console.warn("[Passkey Registration] Verification failed:", err.message || err);
14873
14989
  return new Response(err.message || "Passkey registration failed", { status: 400 });
@@ -15931,6 +16047,12 @@ var HttpServer = class {
15931
16047
  const remoteIp = req.socket.remoteAddress?.replace(/^::ffff:/, "") || "127.0.0.1";
15932
16048
  headers.set("CF-Connecting-IP", remoteIp);
15933
16049
  }
16050
+ if (!headers.has("CF-IPLatitude")) {
16051
+ headers.set("CF-IPLatitude", "0.0");
16052
+ }
16053
+ if (!headers.has("CF-IPLongitude")) {
16054
+ headers.set("CF-IPLongitude", "0.0");
16055
+ }
15934
16056
  let body = null;
15935
16057
  if (req.method !== "GET" && req.method !== "HEAD") {
15936
16058
  const chunks = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dns-worker",
3
- "version": "1.1.0",
3
+ "version": "1.1.1-canary.e00a6a3",
4
4
  "private": false,
5
5
  "description": "Privacy-first DNS & DoH Resolver with modern Web Dashboard, built for Cloudflare Workers & Node.js",
6
6
  "main": "./dist/serverfull.mjs",
@@ -29,7 +29,7 @@
29
29
  "deploy:skipdb": "npm run build && wrangler deploy",
30
30
  "build": "npm run ui:build",
31
31
  "build:pages": "npm run ui:build && npx esbuild src/index.ts --bundle --outfile=static/_worker.js --platform=neutral --format=esm --minify --external:cloudflare:* --external:node:*",
32
- "postinstall": "npm run ui:install",
32
+ "prepare": "npm run ui:install",
33
33
  "db:setup": "wrangler d1 create dns_worker_db",
34
34
  "db:migrate:dev": "wrangler d1 migrations apply dns_worker_db --local",
35
35
  "db:migrate:prod": "echo \"y\" | wrangler d1 migrations apply dns_worker_db --remote",
@@ -40,7 +40,7 @@
40
40
  "lint": "eslint . --ext .ts,.tsx",
41
41
  "ui:dev": "cd web && npm run dev",
42
42
  "ui:build": "cd web && npm run build",
43
- "ui:install": "cd web && npm install -f"
43
+ "ui:install": "cd web && npm install"
44
44
  },
45
45
  "keywords": [
46
46
  "dns",