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 +1 -18
- package/dist/serverfull.mjs +210 -88
- package/package.json +3 -3
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
|
|
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 |
|
package/dist/serverfull.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
2330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2425
|
-
|
|
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
|
-
|
|
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,
|
|
3072
|
+
bitData = new Uint32Array(buffer.buffer, offset, expectedWords);
|
|
3062
3073
|
} else {
|
|
3063
|
-
const copy = new Uint8Array(
|
|
3064
|
-
copy.set(buffer.subarray(8));
|
|
3065
|
-
bitData = new Uint32Array(copy.buffer, 0,
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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.
|
|
11779
|
+
if (clientOriginUrl.origin.toLowerCase() !== expectedOriginUrl.origin.toLowerCase()) {
|
|
11725
11780
|
throw new Error(
|
|
11726
|
-
`WebAuthn origin mismatch: expected ${expectedOriginUrl.
|
|
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.
|
|
11831
|
+
if (clientOriginUrl.origin.toLowerCase() !== expectedOriginUrl.origin.toLowerCase()) {
|
|
11782
11832
|
throw new Error(
|
|
11783
|
-
`WebAuthn origin mismatch: expected ${expectedOriginUrl.
|
|
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
|
-
|
|
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
|
-
|
|
12496
|
+
const rawRecoveryKeys = user.totp_recovery_keys;
|
|
12497
|
+
if (rawRecoveryKeys) {
|
|
12450
12498
|
try {
|
|
12451
|
-
const parsed = JSON.parse(
|
|
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
|
-
|
|
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
|
|
12564
|
-
if (!
|
|
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
|
-
|
|
13349
|
-
|
|
13350
|
-
|
|
13351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
14868
|
-
|
|
14869
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
43
|
+
"ui:install": "cd web && npm install"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"dns",
|