From ef881eee9a6a6fb86e298ba8fa18f9a72c8a7ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=22rysiek=22=20Wo=C5=BAniak?= Date: Sat, 7 Oct 2023 03:14:42 +0000 Subject: [PATCH] DoH wire format implementation, use in dnslink-fetch plugin --- lib/COPYRIGHT.md | 15 + lib/doh.js | 403 +++++ plugins/dnslink-fetch/README.md | 10 +- .../dnslink-fetch/__tests__/browser.test.js | 1579 ++++++++++++++++- plugins/dnslink-fetch/index.html | 10 +- plugins/dnslink-fetch/index.js | 149 +- 6 files changed, 2065 insertions(+), 101 deletions(-) create mode 100644 lib/COPYRIGHT.md create mode 100644 lib/doh.js diff --git a/lib/COPYRIGHT.md b/lib/COPYRIGHT.md new file mode 100644 index 0000000..1215d5f --- /dev/null +++ b/lib/COPYRIGHT.md @@ -0,0 +1,15 @@ +# Copyright + +Copyrights and licenses for files in this directory are as following: + +## JS-IPFS Project + +The following files are part of the [js-ipfs](https://github.com/ipfs/js-ipfs) project, and are licensed under the MIT license: + - `ipfs.js` + +## GunDB Project + +The following files are part of the [GunDB](https://github.com/amark/gun) project, and are licensed under the Apache license: + - `gun.js` + - `sea.js` + - `webrtc.js` diff --git a/lib/doh.js b/lib/doh.js new file mode 100644 index 0000000..465d7f4 --- /dev/null +++ b/lib/doh.js @@ -0,0 +1,403 @@ +// +// implementing some helper functions for DNS-over-HTTPS +// + +/** + * retrieving the alternative endpoints list from DNS TXT records + * using a DNS-over-HTTPS binary DNS wire format endpoint + * + * returns an array of strings, each being a valid endpoint, + * in the form of: + * scheme://example.org[/optional/path] + * + * for an explanation of what the heck is going on here, see: + * https://courses.cs.duke.edu/fall16/compsci356/DNS/DNS-primer.pdf + * + * @param domain (string) domain name to resolve TXT records for + * @param config (object) config object with fields: dohProvider, dohMethod, dohMediaType + * @param log (function) logging function to use (optional) + */ +window.resolveEndpointsBinary = async (domain, config, log=()=>{} ) => { + + // encoder and decoder + let enc = new TextEncoder(); + let dec = new TextDecoder('utf8'); + + // then we need an Uint8Array for the whole thing + // + // how large? glad you asked! + // - header part is 12 bytes + // - question part is (domain.length + 2) + 4 bytes + // + // why? even more glad you asked! + // + // qname part is the part that will contain the name we are trying to resolve. + // this means all the labels, without the dots. + // each label will be preceeded by one-byte field containing the length of the label + // after all labels are included, a one-byte null terminator follows. + // + // so, for "_dnslink.example.org" (length: 20 bytes) we get: + // [len:8]_dnslink[len:7]example[len:3]org[null] (length: 22 bytes) + let qbuf = new Uint8Array(12 + domain.length + 6) + + // let's build ourselves a header + qbuf.set([ + + // ID, 16-bit — request identifier + Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), + + // 16 bits of flags + // + //,-------------- 1-bit QR header field, 0 means "query" + //| ,------------ 4-bit OPCODE header field, we want 0 ("standard query") + //| | ,--------- 1-bit AA header field, only relevant in response (1 means the response is authoritative) + //| | |,-------- 1-bit TC header field, we want 0 signifying the message was not truncated + //| | ||,------- 1-bit RD header field, we want 1 signifying we want recursive resolution + //| | ||| + //| | ||| ,------- 1-bit RA header field, only relevant in response (1 means recursion is available) + //| | ||| | ,----- 3-bit Z header field, reserved for future use, always 0 + //| | ||| | | ,- 4-bit RCODE header field, to be set to 0, and inspected in response for error codes + //| |\ ||| | | /| + //|/ \||| |/ \/ \ + 0b00000001, 0b00000000, + + // QDCOUNT, 16-bit — number of questions (we only have one) + 0x00, 0x01, + + // ANCOUNT, 16-bit — we are not providing answers, only asking questions + 0x00, 0x00, + + // NSCOUNT, 16-bit — we are not providing any nameserver info + 0x00, 0x00, + + // ARCOUNT, 16-bit — no additional records from our side! + 0x00, 0x00 + ]) + + // let's build ourselves a QNAME! + let qbufpos = 12 // starting at byte 12, just as the header ends + domain + .split('.') + .forEach( + (label)=>{ + // set the byte of the buffer at the current position to the length of the label + qbuf[qbufpos] = label.length + // set the encoded label starting from the following byte + qbuf.set(enc.encode(label), qbufpos + 1) + // increment the position by the length of the label plus 1 byte + qbufpos += label.length + 1 + }) + + // in qbufpos at this point we have the position of the byte + // directly following the last letter in the last label + // we need a null byte there to signify end of QNAME + qbuf[qbufpos] = 0x00 + qbufpos += 1 + + // let's finish it all off + qbuf.set([ + + // QTYPE, 160-bit — we want TXT records, which is 16 decimal + // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-4 + 0x00, 0x10, + + // QCLASS, 16-bit — we want Internet (IN) + // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-2 + 0x00, 0x01 + ], qbufpos) + + // request data + let request_data = { + url: config.dohProvider, + options: { + // TODO: we need to find a way to make "no-cors" requests + //mode: "no-cors", + method: config.dohMethod, + headers: { + accept: config.dohMediaType + } + } + } + + // GET or POST? + // https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-wireformat/ + if (config.dohMethod === "POST") { + // binary data in POST body, easy peasy + request_data.options.body = qbuf; + request_data.options.headers["content-length"] = qbuf.length.toString() + request_data.options.headers["content-type"] = config.dohMediaType + + } else if (config.dohMethod === "GET") { + // we need base64-encoded data, since we're putting it the URL directly + // https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + request_data.url += "?dns=" + btoa(String.fromCodePoint(...qbuf)); + + } else { + // that's not right + throw new Error('dohMethod can only be "GET" or "POST"; got: ' + config.dohMethod) + } + + // make the request + let response = await fetch(request_data.url, request_data.options) + + // did we get application/dns-message? + if (response.headers.get("content-type") !== config.dohMediaType) { + throw new Error(`Response Content-Type should be: ${config.dohMediaType}; is: ${response.headers.get("content-type")}.`) + } + + // let's get the actual response + let rbuf = new Uint8Array(await response.arrayBuffer()) + + // the answer cannot be shorter than the question + if (rbuf.length < qbuf.length) { + throw new Error('Invalid response: response cannot be shorter than request!') + } + + // first two bytes have to be the ID, so identical to qbuf's first two bytes + if ( (rbuf[0] !== qbuf[0]) || (rbuf[1] !== qbuf[1]) ) { + throw new Error('Response ID does not match Request ID!') + } + + // is this a response? QR bit needs to be 1 + if ( (rbuf[2] & 0b10000000) != 0b10000000) { + throw new Error('Invalid response: QR bit does not indicate a response!') + } + + // is the OPCODE sane? it needs to be 0b0000 + if ( (rbuf[2] & 0b01111000) != 0b00000000) { + throw new Error('Invalid response: OPCODE contains an unexpected value (should be zero)!') + } + + // there's no good reason for us to get a truncated response + if ( (rbuf[2] & 0b00000010) == 0b00000010) { + throw new Error('Invalid response: Got a truncated response. There is no reason for it, and we cannot handle it.') + } + + // recursive resolution was requested — is that reflected? + if ( (rbuf[2] & 0b0000001) != 0b00000001) { + throw new Error('Invalid response: Recursive resolution was requested but this is not reflected in response.') + } + + // 3-bit Z field is "reserverved for future use" and should be zero + if ( (rbuf[3] & 0b01110000) != 0b00000000) { + throw new Error("Invalid response: Response's Z field is not zeroed out!") + } + + // 4-bit RCODE field should be zero, anything else indicates errors + if ( (rbuf[3] & 0b00001111) != 0b00000000) { + let rcode = (rbuf[3] & 0b00001111) + if (rcode == 1) { + throw new Error("Response's RCODE field indicates a format error!") + } + if (rcode == 2) { + throw new Error("Response's RCODE field indicates a server failure!") + } + if (rcode == 3) { + throw new Error("Response's RCODE field indicates a the name does not exist!") + } + if (rcode == 4) { + throw new Error("Response's RCODE field indicates a not implemented error!") + } + if (rcode == 5) { + throw new Error("Response's RCODE field indicates the request was refused!") + } + throw new Error(`Response's RCODE field indicates an error (code: ${rcode})!`) + } + + // we only asked one question + if ( (rbuf[4] != 0) || (rbuf[5] != 1) ) { + throw new Error("Response's QDCOUNT is different than request's QDCOUNT (should be 1)!") + } + + // how many resource records did we get in the response? + let ancount = (rbuf[6] << 8) + rbuf[7]; + if (ancount < 1) { + throw new Error("Response's ANCOUNT indicates no resource records received in response!") + } else { + log("number of resource records received: " + ancount) + } + + // we are ignoring NSCOUNT and ARCOUNT 16-bit fields (rbuf[8] through rbuf[11]), + // and moving on to question section + let rbufpos = 12 + + // let's see if the question section in the response + // matches the question section in the request + // + // header is the same size in request and response: 12 bytes + // + // qbufpos indicates the end of the QNAME section of the request + // this is followed by four bytes: two for TYPE and two for CLASS + // + // these (QNAME, QTYPE, QCLASS) should all be byte-for-byte equal + // in both request and response + while (rbufpos < qbufpos + 4) { + if (rbuf[rbufpos] != qbuf[rbufpos]) { + throw new Error("Invalid response: QNAME, QTYPE, or QCLASS do not match between request and response.") + } + rbufpos += 1 + } + + // let's make some space for answers + // this is going to be an array of string + let answers = [] + + // consume the response buffer byte by byte + while (rbufpos < rbuf.length) { + + // we are not ignoring answers by default + let ignore_answer = false + + // at this point we should have our NAME (same as QNAME) in the response + // if it starts with 0x11nn, it's a pointer to a QNAME/NAME, + // and the rest of the two bytes is the offset from the start of the response + + // is this a pointer or a NAME? + if ( (rbuf[rbufpos] & 0b11000000) == 0b11000000 ) { + // a pointer! we should expect the rest of the two bytes + // to be an offset from the start of the response + // and it only makes sense here for the offset to be equal + // to 12 — the first byte after the header + if ( ((rbuf[rbufpos] & 0b00111111) << 8) + rbuf[rbufpos+1] != 12) { + throw new Error(`Invalid response: unexpected name pointer offset ${((rbuf[rbufpos] & 0b00111111) << 8) + rbuf[rbufpos+1]} (expected: 12)`) + } + rbufpos += 2; + + } else if ( (rbuf[rbufpos] & 0b11000000) == 0b00000000 ) { + // so we get a full name, that should be identical + // to QNAME from the question section — length of which is (qbufpos - 12) bytes, + // when accounted for the header + for (let i=0; i < (qbufpos - 12); i++) { + if (rbuf[rbufpos + i] != qbuf[i + 12]) { + throw new Error("Invalid response: NAME in an answer does not match the QNAME from the request.") + } + } + rbufpos += (qbufpos - 12) + + // this should never happen + } else { + throw new Error('Invalid response: answer\'s NAME starts with something else than 0b11 or 0b00.') + } + + // two bytes of TYPE; we are asking for TXT, which is 16 + if ( ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]) != 16) { + // we don't care about any other type + // but we can't just bail, we need to verify the validity of the whole answer section + ignore_answer = true + log("Not a TXT record, ignoring.") + } + rbufpos += 2 + + // two bytes of CLASS; we are only interested in IN ("Internet") class, which is 1 + if ( ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]) != 1) { + throw new Error("Invalid response: unexpected CLASS: " + ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1])) + } + rbufpos += 2 + + // four bytes of TTL.! + let ttl = ((rbuf[rbufpos] << 24) + (rbuf[rbufpos+1] << 16) + (rbuf[rbufpos+2] << 8) + rbuf[rbufpos+3]) + rbufpos += 4 + + // 16-bit length of the RDATA field. that we definitely care about! + let rdlength = ((rbuf[rbufpos] << 8) + rbuf[rbufpos+1]) + // "An empty TXT record containing zero strings is not allowed [RFC1035]." + if (rdlength < 1) { + throw new Error("Invalid response: RDLENGTH is zero") + } + rbufpos += 2 + + // are we interested in this answer at all? + if (ignore_answer) { + // skip! + rbufpos += rdlength; + continue; + } + + // "The format of each constituent string within the DNS TXT record is a + // single length byte, followed by 0-255 bytes of text data." + // https://www.ietf.org/rfc/rfc6763.txt + // + // in other words we have the length of the TXT record itself specified again within RDATA + // this obviously should be 1 less than RDLENGTH + // + // TODO: we are assuming here that the RDATA only contains one string; + // TODO: *technically* RDATA *can* contain multiple strings for TXT RRs, but that seems to not be used much? + if (rdlength != rbuf[rbufpos] + 1) { + throw new Error("Invalid response: RDLENGTH does not match TXT record length") + } + rbufpos += 1 + + // we are interested! okay! + answers.push(dec.decode(rbuf.slice(rbufpos, rbufpos + rdlength - 1))) + log(`relevant answer received: '${answers[answers.length - 1]}' (TTL: ${ttl})`) + rbufpos += rdlength - 1; + + // did we get all the answers we expected based on ANCOUNT? + if (ancount <= answers.length) { + // great, we can ignore the rest of the response packet + log(`got all ${ancount} expected answers.`) + break + } + } + + // this should be an array of strings + return answers; +} + +/** + * retrieving the alternative endpoints list from DNS TXT records + * using a DNS-over-HTTPS JSON endpoint + * + * returns an array of strings, one per each TXT record + * + * @param domain (string) domain name to resolve TXT records for + * @param config (object) config object with fields: dohProvider, dohMethod, dohMediaType + */ +window.resolveEndpointsJSON = async (domain, config) => { + // pretty self-explanatory: + // DoH provider, domain, TXT type, pretty please + var query = `${config.dohProvider}?name=${domain}&type=TXT` + + // make the query, get the response + var response = await fetch( + query, { + // TODO: we need to find a way to make "no-cors" requests + //mode: "no-cors", + headers: { + 'accept': 'application/json', + } + }) + // this will throw an error if the response fails to parse as JSON + .then(r=>r.json()) + + // we need an object here + // we could get a "JSON" that is just a string, as long as it is in quote-marks, apparently + if (typeof response !== 'object') { + throw new Error('Response is not a valid JSON') + } + + // only Status == 0 is acceptable + // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 + if (!('Status' in response) || response.Status != 0) { + throw new Error(`DNS request failure, status code: ${response.Status}`) + } + + // we also do need the Answer section please + if (!('Answer' in response) || (typeof response.Answer !== 'object') || (!Array.isArray(response.Answer))) { + throw new Error(`DNS response did not contain a valid Answer section`) + } + + // only get TXT records, and extract the data from them + response = response + .Answer + .filter(r => r.type == 16) + .map(r => r.data); + + // did we get anything of value? anything at all? + if (response.length < 1) { + throw new Error(`Answer section of the DNS response did not contain any TXT records`) + } + + // this should be an array of strings, containing the TXT records + return response +} diff --git a/plugins/dnslink-fetch/README.md b/plugins/dnslink-fetch/README.md index b6a1454..052acd3 100644 --- a/plugins/dnslink-fetch/README.md +++ b/plugins/dnslink-fetch/README.md @@ -25,15 +25,19 @@ The `dnslink-fetch` plugin supports the following configuration options: CloudFlare's DoH JSON API endpoint - "`https://mozilla.cloudflare-dns.com/dns-query`" Mozilla's DoH JSON API endpoint, operated in co-operation with CloudFlare. + + - `dohMediaType` (default: `application/dns-json`) + Media type to use in requests. When set to `application/dns-message`, requests will be made using the binary DNS wire format. If set to anything else, they will be made using the JSON format. JSON format is much less popular (many fewer available public DoH resolvers support it), but handles internationalized domain names (IDNs) better. - - `ecsMasked` (default: `true`) - Should the [EDNS Client Subnet](https://en.wikipedia.org/wiki/EDNS_Client_Subnet) be masked from authoritative DNS servers for privacy. See also: `edns_client_subnet` [parameter of the DoH JSON API](https://developers.google.com/speed/public-dns/docs/doh/json#supported_parameters). - + - `dohMethod` (default: `GET`) + The HTTP method to use when making DNS-over-HTTPS requests. It can only be "`POST`" or "`GET`". It is only meaningful when `dohMediaType` is set to `application/dns-message` -- that is, only when the binary DNS wire format is being used. DoH JSON API requests can only be made using HTTP `GET`, so `dohMethod` setting is ignored when the `dohMediaType` is set to anything other than "`application/dns-message`". ## Operation When fetching an URL, `dnslink-fetch` removes the scheme and domain component. Then, for each alternative endpoint that is used for this particular request (up to `concurrency` of endpoints, as described above), it concatenates the endpoint with the remaining URL part. Finally, it performs a [`fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) request for every URL construed in such a way. +DNS requests are made either using the DNS-over-HTTPS JSON format (default), or using the DNS wire format (when `dohMediaType` is set to "`application/dns-message`"). JSON format handles internationalized domain names (IDNs) better, but there are fewer available public resolvers that support it. + Let's say the plugin is deployed for website `https://example.com`, with `concurrency` set to `2` and these are the alternative endpoints specified in relevant TXT records according to the DNSLink specification (so, in [multiaddr form](https://github.com/multiformats/multiaddr#encapsulation-based-on-context)): - `dnslink=/https/example.org` - `dnslink=/https/example.net/alt-example` diff --git a/plugins/dnslink-fetch/__tests__/browser.test.js b/plugins/dnslink-fetch/__tests__/browser.test.js index f0f00d8..ae7b26e 100644 --- a/plugins/dnslink-fetch/__tests__/browser.test.js +++ b/plugins/dnslink-fetch/__tests__/browser.test.js @@ -9,7 +9,8 @@ import { assert, assertThrows, assertRejects, - assertEquals + assertEquals, + assertNotEquals } from "https://deno.land/std@0.183.0/testing/asserts.ts"; import { @@ -18,12 +19,118 @@ import { spy, } from "https://deno.land/std@0.183.0/testing/mock.ts"; + +// elements of a query and of a response +const dohPktFrgs = { + // === header section === + // 16 bits of flags: + // + // ,-------------- 1-bit QR header field, 0 means "question", 1 - "response" + // | ,------------ 4-bit OPCODE header field, we want 0 ("standard query") + // | | ,--------- 1-bit AA header field, only relevant in response (1 means the response is authoritative) + // | | |,-------- 1-bit TC header field, we want 0 signifying the message was not truncated + // | | ||,------- 1-bit RD header field, we want 1 signifying we want recursive resolution + // | | ||| + // | | ||| ,------- 1-bit RA header field, only relevant in response (1 means recursion is available) + // | | ||| | ,----- 3-bit Z header field, reserved for future use, always 0 + // | | ||| | | ,- 4-bit RCODE header field, to be set to 0, and inspected in response for error codes + // | |\ ||| | | /| + // |/ \||| |/ \/ \ + qflags: [0b00000001, 0b00000000], + rflags: [0b10000001, 0b10000000], + // === question section === + // _dnslink.resilient.is + name: [8,95,100,110,115,108,105,110,107,9,114,101,115,105,108,105,101,110,116,2,105,115,0], + type: [0,16], + class: [0,1], + // === answer section === + // name pointer always starts with 0b11, and in our case it only makes sense + // that offset is equal to 12 (i.e. first byte of the qustion section, right after the header) + nameptr: [0b11000000, 12], + ttl: [0,0,13,110], // some positive number + // 1-byte length, followed by string response + dnslink: [100,110,115,108,105,110,107,61], // the "dnslink=" string + ipfs: [47,105,112,102,115,47], // the "/ipfs/" string + https: [47,104,116,116,112,115,47], // the "/https/" string + // example.com/test + https_addr1: [101,120,97,109,112,108,101,46,99,111,109,47,116,101,115,116], + // example.org + https_addr2: [101,120,97,109,112,108,101,46,111,114,103], + // QmiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFSiPFS + ipfs_addr: [81,109,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83,105,80,70,83], + // === additional section === + // this should be ignored when interpreting the response + additional: [0,0,41,4,208,0,0,0,0,0,0] +} + +/** + * simple yet effective + * + * examples: + * + * - valid single question packet: + * dohPacket([ + * [0, 1], dohPktFrgs.qflags, [0, 1], [0, 0], [0, 0], [0, 0], + * dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class]) + * + * - valid single answer packet, with name repeated in full: + * dohPacket([ + * [0, 1], dohPktFrgs.rflags, [0, 1], [0, 1], [0, 0], [0, 0], + * dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, + * dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + * [0, (dohPktFrgs.dnslink.length + dohPktFrgs.ipfs.length + dohPktFrgs.ipfs_addr.length + 1)], + * [(dohPktFrgs.dnslink.length + dohPktFrgs.ipfs.length + dohPktFrgs.ipfs_addr.length)], + * dohPktFrgs.dnslink, dohPktFrgs.ipfs, dohPktFrgs.ipfs_addr]) + * + * - valid single answer packet, with name pointed to: + * dohPacket([ + * [0, 1], dohPktFrgs.rflags, [0, 1], [0, 1], [0, 0], [0, 0], + * dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, + * dohPktFrgs.nameptr, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + * [0, (dohPktFrgs.dnslink.length + dohPktFrgs.ipfs.length + dohPktFrgs.ipfs_addr.length + 1)], + * [(dohPktFrgs.dnslink.length + dohPktFrgs.ipfs.length + dohPktFrgs.ipfs_addr.length)], + * dohPktFrgs.dnslink, dohPktFrgs.ipfs, dohPktFrgs.ipfs_addr]) + */ +let dohPacket = (elements) => { + // can't just elements.flat(), because that does not + // flatten Uint8Arrays, and that's what we really care about + return Uint8Array + .from( + elements + .map((el)=>{ + if (el.constructor === Uint8Array) { + return Array.from(el) + } else { + return el + } + }) + .flat()) +} + +let decodeUrlDoHRequest = (request) => { + return Uint8Array.from( + Array.from( + atob( + request + ) + ).map(a => a.charCodeAt(0)) + ) +} + beforeAll(async ()=>{ window.fetchResponse = [] window.resolvingFetch = (url, init) => { + let response_data = null + if (typeof window.fetchResponse[0] === 'object' && window.fetchResponse[0] != null) { + response_data = JSON.stringify(window.fetchResponse[0]); + } else if (typeof window.fetchResponse[0] === "function") { + response_data = window.fetchResponse[0](url, init) + } else { + response_data = window.fetchResponse[0] + } const response = new Response( new Blob( - [JSON.stringify(window.fetchResponse[0])], + [response_data], {type: window.fetchResponse[1]} ), { @@ -58,20 +165,21 @@ beforeEach(()=>{ window.init = { ...window.initPrototype } + window.LR = { + log: spy((component, ...items)=>{ + console.debug(component + ' :: ', ...items) + }) + } }) describe('browser: dnslink-fetch plugin', async () => { window.LibResilientPluginConstructors = new Map() - window.LR = { - log: (component, ...items)=>{ - console.debug(component + ' :: ', ...items) - } - } window.fetchResponse = [] window.resolvingFetch = null window.fetch = null + await import("../../../lib/doh.js"); await import("../../../plugins/dnslink-fetch/index.js"); it("should register in LibResilientPluginConstructors", () => { @@ -93,9 +201,97 @@ describe('browser: dnslink-fetch plugin', async () => { Error, 'dohProvider not confgured' ) + init = { + name: 'dnslink-fetch', + dohMediaType: false + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('dnslink-fetch')(LR, init) + }, + Error, + 'dohMediaType not confgured' + ) + init = { + name: 'dnslink-fetch', + dohMethod: false + } + assertThrows( + ()=>{ + return LibResilientPluginConstructors.get('dnslink-fetch')(LR, init) + }, + Error, + 'dohMethod not confgured' + ) }); - it("should perform a fetch against the default dohProvider endpoint, with default ECS settings", async () => { + it("should warn if dohMethod and dohMediaType are set such that dohMethod will be ignored", async () => { + init = { + name: 'dnslink-fetch', + dohMethod: "POST", + dohMediaType: "application/dns-json" + } + // this will fail. that's okay. + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch (e) {} + + assertEquals( + LR.log.calls[0].args[1], + 'Warning: "POST" dohMethod is going to be ignored as dohMediaType is not "application/dns-message"' + ) + }); + + it("should fail if doh.js is not correctly loaded", async () => { + + let f = window.resolveEndpointsJSON + window.resolveEndpointsJSON = undefined + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'resolveEndpointsJSON() is not defined, is doh.js loaded?' + ) + + window.resolveEndpointsJSON = f + f = window.resolveEndpointsBinary + window.resolveEndpointsBinary = undefined + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'resolveEndpointsBinary() is not defined, is doh.js loaded?' + ) + + window.resolveEndpointsBinary = f + }) + + it("should attempt to load doh.js if importScripts() is available", async () => { + window.importScripts = spy() + // this will fail. that's okay. + let plugin = LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + assertEquals( + LR.log.calls[0].args[1], + 'importing doh.js' + ) + assertEquals( + window.importScripts.calls[0].args[0], + "./lib/doh.js" + ) + }); + + it("should perform a fetch against the default dohProvider endpoint, with default settings", async () => { // this will fail after the fetch() is done // but we only care about the fetch() being done in this test @@ -110,40 +306,29 @@ describe('browser: dnslink-fetch plugin', async () => { 0, { args: [ - "https://dns.hostux.net/dns-query?name=_dnslink.resilient.is&type=TXT&edns_client_subnet=0.0.0.0/0", + "https://dns.hostux.net/dns-query?name=_dnslink.resilient.is&type=TXT", {"headers": {"accept": "application/json"}} ] }) }) - it("should perform a fetch against the configured dohProvider endpoint, with configured ECS settings", async () => { - - init.dohProvider = 'https://doh.example.org/resolve-example' - init.ecsMasked = false - - // this will fail after the fetch() is done - // but we only care about the fetch() being done in this test - try { - await LibResilientPluginConstructors - .get('dnslink-fetch')(LR, init) - .fetch('https://resilient.is/test.json'); - } catch(e) {} - - assertSpyCall( - fetch, - 0, - { - args: [ - "https://doh.example.org/resolve-example?name=_dnslink.resilient.is&type=TXT", - {"headers": {"accept": "application/json"}} - ] - }) - }) - - it("should throw an error if the DoH response is not a valid JSON", async () => { + it("should throw an error if the DoH JSON API response is not a valid JSON", async () => { window.fetchResponse = ["not-json", "text/plain"] + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Unexpected token \'o\', "not-json" is not valid JSON' + ) + + // technically, a quoted string parses as JSON + window.fetchResponse = ["\"not-json\"", "text/plain"] + assertRejects( async ()=>{ return await LibResilientPluginConstructors @@ -155,8 +340,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response is does not have a Status field", async () => { - + it("should throw an error if the DoH JSON API response is does not have a Status field", async () => { window.fetchResponse = [{test: "success"}, "application/json"] @@ -171,7 +355,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response has Status other than 0", async () => { + it("should throw an error if the DoH JSON API response has Status other than 0", async () => { window.fetchResponse = [{Status: 999}, "application/json"] @@ -186,7 +370,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response does not have an Answer field", async () => { + it("should throw an error if the DoH JSON API response does not have an Answer field", async () => { window.fetchResponse = [{Status: 0}, "application/json"] @@ -201,7 +385,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response's Answer field is not an object", async () => { + it("should throw an error if the DoH JSON API response's Answer field is not an object", async () => { window.fetchResponse = [{Status: 0, Answer: 'invalid'}, "application/json"] @@ -216,7 +400,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response's Answer field is not an Array", async () => { + it("should throw an error if the DoH JSON API response's Answer field is not an Array", async () => { window.fetchResponse = [{Status: 0, Answer: {}}, "application/json"] @@ -231,7 +415,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response's Answer field does not contain TXT records", async () => { + it("should throw an error if the DoH JSON API response's Answer field does not contain TXT records", async () => { window.fetchResponse = [{Status: 0, Answer: ['aaa', 'bbb']}, "application/json"] @@ -246,7 +430,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response's Answer elements do not contain valid endpoint data", async () => { + it("should throw an error if the DoH JSON API response's Answer elements do not contain valid endpoint data", async () => { window.fetchResponse = [{Status: 0, Answer: [{type: 16}, {type: 16}]}, "application/json"] @@ -261,7 +445,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should throw an error if the DoH response's Answer elements do not contain valid endpoints", async () => { + it("should throw an error if the DoH JSON API response's Answer elements do not contain valid endpoints", async () => { window.fetchResponse = [{Status: 0, Answer: [{type: 16, data: 'aaa'}, {type: 16, data: 'bbb'}]}, "application/json"] @@ -276,7 +460,7 @@ describe('browser: dnslink-fetch plugin', async () => { ) }) - it("should successfully resolve if the DoH response contains endpoint data", async () => { + it("should successfully resolve if the DoH JSON API response contains endpoint data", async () => { window.fetchResponse = [ {Status: 0, Answer: [ @@ -317,6 +501,1313 @@ describe('browser: dnslink-fetch plugin', async () => { }) }) + it("should use the configured method when using DoH wire format", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // we should be doing a GET + assertEquals(fetch.calls[0].args[1].method, "GET") + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // we should be doing a POST + assertEquals(fetch.calls[1].args[1].method, "POST") + }) + + it("should error out when DoH wire format request method is other than GET or POST", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "OPTIONS" + } + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'dohMethod can only be "GET" or "POST", but is set to: OPTIONS' + ) + }) + + it("should set Accept, Content-Length, and Content-Type headers correctly when the DoH wire format", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // we should have Accept set when doing a GET + assertEquals( + fetch.calls[0].args[1].headers['accept'], + 'application/dns-message' + ) + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // we should have Accept, Content-Length, and Content-Type set when doing a POST + assertEquals( + fetch.calls[1].args[1].headers['accept'], + 'application/dns-message' + ) + assertEquals( + fetch.calls[1].args[1].headers['content-type'], + 'application/dns-message' + ) + assertEquals( + fetch.calls[1].args[1].headers['content-length'], + '39' + ) + }) + + it("should set the request data correctly when using the DoH wire format", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + // DNS wire format query packet without the two random ID bytes at the start + let qpacket = dohPacket([ + dohPktFrgs.qflags, [0, 1], [0, 0], [0, 0], [0, 0], + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class + ]) + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertEquals( + // strip the actual query off of the URL, base64-decode it, + // put it in an Uint8Array, and drop the first two bytes (which are randomly generated) + decodeUrlDoHRequest( + fetch.calls[0].args[0].split('?dns=')[1] + ).slice(2), + // comparing to a DoH wire format packet without the ID field + qpacket + ) + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertEquals( + // request body without the first two bytes of randomly generated ID field + fetch.calls[1].args[1].body.slice(2), + // comparing to a DoH wire format packet without the ID field + qpacket + ) + }) + + it("should set the request ID field randomly when using the DoH wire format", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + // this will also error out, and that's fine + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + // this is not *entirely* repeatable, as there is a *slight* chance + // that the random values will turn out the same. + // + // that probability is pretty small though. + // it will hit us at somepoint, but it won't hit us very often + assertNotEquals( + // strip the actual query off of the URL, base64-decode it, + // put it in an Uint8Array, and only use the first two butes (randomly generated request ID + decodeUrlDoHRequest( + fetch.calls[0].args[0].split('?dns=')[1] + ).slice(0, 2), + decodeUrlDoHRequest( + fetch.calls[1].args[0].split('?dns=')[1] + ).slice(0, 2), + "ID values are randomly generated and should differ, although there is a tiny chance that they matched randomly." + ) + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + // this will error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + // this will also error out. that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertNotEquals( + // request body without the first two bytes of randomly generated ID field + fetch.calls[2].args[1].body.slice(0, 2), + // comparing to a DoH wire format packet without the ID field + fetch.calls[3].args[1].body.slice(0, 2), + "ID values are randomly generated and should differ, although there is a tiny chance that they matched randomly." + ) + }) + + it("should generate a valid DoH wire format question packet", async () => { + + // valid question packet, without the first two ID bytes (which are randomly generated) + let qpacket = dohPacket([ + dohPktFrgs.qflags, [0, 1], [0, 0], [0, 0], [0, 0], + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class]) + + // we want DoH wire format, GET method + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + // this will fail, that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertEquals( + decodeUrlDoHRequest( + fetch.calls[0].args[0].split('?dns=')[1] + ).slice(2), + qpacket + ) + + // we want DoH wire format, GET method + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + // this will fail, that's okay + try { + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + } catch(e) {} + + assertEquals( + fetch.calls[1].args[1].body.slice(2), + qpacket + ) + + }) + + it("should throw an error if the DoH wire format response is not an application/dns-message media type", async () => { + + window.fetchResponse = ["not-dns-message", "text/plain"] + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response Content-Type should be: application/dns-message; is: text/plain.' + ) + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response Content-Type should be: application/dns-message; is: text/plain.' + ) + }) + + it("should throw an error if a DoH wire format response's header is not correctly formatted ", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + window.fetchResponse = [ + "invalid", + "application/dns-message"] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: response cannot be shorter than request!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // modify the ID + response[0] += 1 + response[1] += 1 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response ID does not match Request ID!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // modify the QR bit, needs to be 0b1xxxxxxx to indicate a response + response[2] = 0b00000000 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: QR bit does not indicate a response!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // modify the OPCODE flag, needs to be 0bx0000xxx in a valid response + response[2] = 0b11010000 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: OPCODE contains an unexpected value (should be zero)!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // modify the TC bit, needs to be 0bxxxxxx0x in a valid response we can handle + response[2] = 0b10000010 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: Got a truncated response. There is no reason for it, and we cannot handle it.' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // modify the RD bit, needs to be 0bxxxxxxx1 in a valid response + response[2] = 0b10000000 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: Recursive resolution was requested but this is not reflected in response.' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the Z field, needs to be 0bx000xxxx in a valid response + response[3] = 0b11110000 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: Response\'s Z field is not zeroed out!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, 1 means format error + response[3] = 0b00000001 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates a format error!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, 2 means server failure + response[3] = 0b00000010 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates a server failure!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, 3 means the name does not exist + response[3] = 0b00000011 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates a the name does not exist!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, 4 means not implemented error + response[3] = 0b00000100 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates a not implemented error!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, 5 means the request was refused + response[3] = 0b00000101 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates the request was refused!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // modify the RCODE field, any code > 5 is an error as well + response[3] = 0b00001101 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s RCODE field indicates an error (code: 13)!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // QDCOUNT needs to be 1 (we only asked one question) + response[4] = 0 + response[5] = 0 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s QDCOUNT is different than request\'s QDCOUNT (should be 1)!' + ) + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + response[6] = 0 + response[7] = 0 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Response\'s ANCOUNT indicates no resource records received in response!' + ) + + }) + + it("should throw an error if a DoH wire format response's question section is not correctly formatted", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + window.fetchResponse = [ + (url)=>{ + // take the request + let response = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + response[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + response[6] = 0 + response[7] = 1 + // question section needs to be otherwise repeated one-to-one + // so let's modify it in random ways + response[14] += 5 + response[17] -= 3 + response[response.length - 3] += 2 + response[response.length - 1] += 9 + // now it is our response + return response + }, + "application/dns-message" + ] + + assertRejects( + async ()=>{ + return await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: QNAME, QTYPE, or QCLASS do not match between request and response.' + ) + + }) + + it("should process a valid DoH wire format response to a GET request and use the alternative endpoint it contains", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + window.fetchResponse = [ + (url, reqinit)=>{ + // after the DoH wire format request we need to handle also the request to the alternative endpoint + if (url == "https://example.com/test/test.json") { + return JSON.stringify({test: "success"}) + // this is handling the DoH wire format request + } else { + // take the request + let request = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + request[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + request[6] = 0 + request[7] = 1 + // responses start after the question section + // so we can just bolt it directly to the request and call it a day, kind of + let response = dohPacket([ + request, + ...response_data + ]) + // now it is our response + return response + } + }, + "application/dns-message" + ] + + let response_data = [ + // name, type, class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + // this should succeed + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + + assertSpyCall(fetch, 1, {args: [ + "https://example.com/test/test.json", + { + cache: "reload", + }] + }) + + // now same, but with a name pointer instead of a full name in the response + response_data = [ + // pointer to the name in question part, type, class, TTL + dohPktFrgs.nameptr, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + // this should succeed + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + + assertSpyCall(fetch, 3, {args: [ + "https://example.com/test/test.json", + { + cache: "reload", + }] + }) + }) + + it("should process a valid DoH wire format response to a POST request and use the alternative endpoint it contains", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + window.fetchResponse = [ + (url, reqinit)=>{ + // after the DoH wire format request we need to handle also the request to the alternative endpoint + if (url == "https://example.com/test/test.json") { + return JSON.stringify({test: "success"}) + // this is handling the DoH wire format request + } else { + // take the request body + let request = reqinit.body + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + request[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + request[6] = 0 + request[7] = 1 + // responses start after the question section + // so we can just bolt it directly to the request and call it a day, kind of + let response = dohPacket([ + request, + ...response_data + ]) + // now it is our response + return response + } + }, + "application/dns-message" + ] + + let response_data = [ + // name, type, class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + // this should succeed + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + + assertSpyCall(fetch, 1, {args: [ + "https://example.com/test/test.json", + { + cache: "reload", + }] + }) + + // now same, but with a name pointer instead of a full name in the response + response_data = [ + // pointer to the name in question part, type, class, TTL + dohPktFrgs.nameptr, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + // this should succeed + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + + assertSpyCall(fetch, 3, {args: [ + "https://example.com/test/test.json", + { + cache: "reload", + }] + }) + }) + + it("should error out on invalid DoH wire format responses to a GET request", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "GET" + } + + window.fetchResponse = [ + (url, reqinit)=>{ + // after the DoH wire format request we need to handle also the request to the alternative endpoint + if (url == "https://example.com/test/test.json") { + return JSON.stringify({test: "success"}) + // this is handling the DoH wire format request + } else { + // take the request + let request = decodeUrlDoHRequest( + url.split('?dns=')[1] + ) + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + request[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + request[6] = 0 + request[7] = 1 + // responses start after the question section + // so we can just bolt it directly to the request and call it a day, kind of + let response = dohPacket([ + request, + ...response_data + ]) + // now it is our response + return response + } + }, + "application/dns-message" + ] + + // invalid response: invalid pointer offset + let response_data = [ + // invalid nameptr, type, class, TTL + dohPktFrgs.nameptr[0], dohPktFrgs.nameptr[1] + 5, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: unexpected name pointer offset 17 (expected: 12)' + ) + + // invalid response: wrong name + let badname = [...dohPktFrgs.name] + badname[4] += 1 + badname[5] += 1 + response_data = [ + // wrong name, type, class, TTL + badname, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: NAME in an answer does not match the QNAME from the request.' + ) + + // invalid response: the first byte of the response has to start + // with 0b11 (a name pointer) or 0b00 (a name) + badname = [...dohPktFrgs.name] + badname[0] = badname[0] | 0b01000000; + response_data = [ + // bad byte name, type, class, TTL + badname, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: answer's NAME starts with something else than 0b11 or 0b00." + ) + + // invalid response: type not indicating a TXT record + response_data = [ + // name, invalid type, class, TTL + dohPktFrgs.name, dohPktFrgs.type[0], dohPktFrgs.type[1] + 4, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "No TXT record contained http or https endpoint definition" + ) + + // invalid response: incorrect class + response_data = [ + // name, type, incorrect class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class[0], dohPktFrgs.class[1] + 10, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: unexpected CLASS: 11" + ) + + // invalid response: RDLENGTH must not be zero + response_data = [ + // name, type, class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // incorrect RDLENGTH + [0, 0], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: RDLENGTH is zero" + ) + + // invalid response: RDLENGTH must be 1 higher than TXT RR length field + response_data = [ + // name, type, incorrect class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // incorrect TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: RDLENGTH does not match TXT record length" + ) + }) + + it("should error out on invalid DoH wire format responses to a POST request", async () => { + + init = { + name: "dnslink-fetch", + dohMediaType: "application/dns-message", + dohMethod: "POST" + } + + window.fetchResponse = [ + (url, reqinit)=>{ + // after the DoH wire format request we need to handle also the request to the alternative endpoint + if (url == "https://example.com/test/test.json") { + return JSON.stringify({test: "success"}) + // this is handling the DoH wire format request + } else { + // take the request + let request = reqinit.body + // response[2] needs to be valid for a response + // we're taking this from a request, so we need to massage it + request[2] = 0b10000001 + // ANCOUNT needs to be 1 or higher (we do want a response) + request[6] = 0 + request[7] = 1 + // responses start after the question section + // so we can just bolt it directly to the request and call it a day, kind of + let response = dohPacket([ + request, + ...response_data + ]) + // now it is our response + return response + } + }, + "application/dns-message" + ] + + // invalid response: invalid pointer offset + let response_data = [ + // invalid nameptr, type, class, TTL + dohPktFrgs.nameptr[0], dohPktFrgs.nameptr[1] + 5, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: unexpected name pointer offset 17 (expected: 12)' + ) + + // invalid response: wrong name + let badname = [...dohPktFrgs.name] + badname[4] += 1 + badname[5] += 1 + response_data = [ + // wrong name, type, class, TTL + badname, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + 'Invalid response: NAME in an answer does not match the QNAME from the request.' + ) + + // invalid response: the first byte of the response has to start + // with 0b11 (a name pointer) or 0b00 (a name) + badname = [...dohPktFrgs.name] + badname[0] = badname[0] | 0b01000000; + response_data = [ + // bad byte name, type, class, TTL + badname, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: answer's NAME starts with something else than 0b11 or 0b00." + ) + + // invalid response: type not indicating a TXT record + response_data = [ + // name, invalid type, class, TTL + dohPktFrgs.name, dohPktFrgs.type[0], dohPktFrgs.type[1] + 4, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "No TXT record contained http or https endpoint definition" + ) + + // invalid response: incorrect class + response_data = [ + // name, type, incorrect class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class[0], dohPktFrgs.class[1] + 10, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: unexpected CLASS: 11" + ) + + // invalid response: RDLENGTH must not be zero + response_data = [ + // name, type, class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // incorrect RDLENGTH + [0, 0], + // TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: RDLENGTH is zero" + ) + + // invalid response: RDLENGTH must be 1 higher than TXT RR length field + response_data = [ + // name, type, incorrect class, TTL + dohPktFrgs.name, dohPktFrgs.type, dohPktFrgs.class, dohPktFrgs.ttl, + // RDLENGTH + [0, (dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // incorrect TXT RR length + [(dohPktFrgs.dnslink.length + dohPktFrgs.https.length + dohPktFrgs.https_addr1.length + 1)], + // TXT RR: dnslink=/https/... + dohPktFrgs.dnslink, dohPktFrgs.https, dohPktFrgs.https_addr1 + ] + + assertRejects( + async ()=>{ + await LibResilientPluginConstructors + .get('dnslink-fetch')(LR, init) + .fetch('https://resilient.is/test.json'); + }, + Error, + "Invalid response: RDLENGTH does not match TXT record length" + ) + }) + it("should fetch the content, trying all DNSLink-resolved endpoints (if fewer or equal to concurrency setting)", async () => { window.fetchResponse = [ diff --git a/plugins/dnslink-fetch/index.html b/plugins/dnslink-fetch/index.html index 099f955..7a11023 100644 --- a/plugins/dnslink-fetch/index.html +++ b/plugins/dnslink-fetch/index.html @@ -6,8 +6,16 @@ self.log = console.log + diff --git a/plugins/dnslink-fetch/index.js b/plugins/dnslink-fetch/index.js index 5f5e79c..80e43c0 100644 --- a/plugins/dnslink-fetch/index.js +++ b/plugins/dnslink-fetch/index.js @@ -25,12 +25,40 @@ // how many simultaneous connections to different endpoints do we want // // more concurrency means higher chance of a request succeeding - // but uses more bandwidth and other resources; + // but uses more bandwidth and other resources. + // + // this is not about DoH resolution, only one DoH endpoint *can* be + // configured and used at any time. this is about concurrency once the + // alternative endpoints are already resolved. that is, how many endpoints + // are used simultaneously to pull the actual content. // // 3 seems to be a reasonable default concurrency: 3, - // DNS-over-HTTPS JSON API provider + // DNS-over-HTTPS media type + // + // if this is set to "application/dns-message", the binary DNS wire format + // will be used and expected. + // + // if this is set to anything else, JSON format will be used and expected. + // when using the JSON format, it is probably safest to set it to "application/dns-json" + // as this media type seems to be accepted the widest and is required by CloudFlare. + // + // other often-used media types for DoH JSON API requests: + // - application/dns+json (RFC8427, registered with IANA) + // - application/x-javascript (Google, but accepts other media types as well) + // - application/json + // + // by default DoH JSON API is used + dohMediaType: "application/dns-json", + + // should the requests to the DoH server be made using GET or POST HTTP method? + // + // this is *only* valid when dohMediaType above is set to "application/dns-message"; + // otherwise it is ignored and GET HTTP method is used + dohMethod: "GET", + + // DNS-over-HTTPS provider // // by default using Hostux DoH provider, info here: // - https://dns.hostux.net/en/ @@ -39,78 +67,93 @@ // - 'https://cloudflare-dns.com/dns-query' // - 'https://mozilla.cloudflare-dns.com/dns-query' // - 'https://dns.google/resolve' - dohProvider: 'https://dns.hostux.net/dns-query', - - // should the EDNS Client Subnet be masked from authoritative DNS servers for privacy? - // - https://en.wikipedia.org/wiki/EDNS_Client_Subnet - // - https://developers.google.com/speed/public-dns/docs/doh/json#supported_parameters - ecsMasked: true + // - 'https://dns.alidns.com/resolve' + // - 'https://dns.quad9.net:5053/dns-query' + dohProvider: 'https://dns.hostux.net/dns-query' } // merge the defaults with settings from the init var let config = {...defaultConfig, ...init} + // reality check: dohMediaType must be a string + if (typeof(config.dohMediaType) !== "string" || (config.dohMediaType == '')) { + let err = new Error("dohMediaType not confgured") + console.error(err) + throw err + } + + // reality check: dohMethod must be a string, and either "GET" or "POST" + // also, "POST" only makes sense when using "application/dns-message" dohMediaType + if (typeof(config.dohMethod) !== "string" || (config.dohMethod == '')) { + let err = new Error("dohMethod not confgured") + console.error(err) + throw err + } else { + // this should be uppercase + config.dohMethod = config.dohMethod.toUpperCase() + // and only GET or POST + if (config.dohMethod != "GET" && config.dohMethod != "POST") { + let err = new Error(`dohMethod can only be "GET" or "POST", but is set to: ${config.dohMethod}`) + console.error(err) + throw err + } + // "POST" only makes sense when dohMediaType is "application/dns-message" + if (config.dohMethod === "POST" && config.dohMediaType != "application/dns-message") { + // this is just a warning though, "POST" is just going to be ignored + LR.log(pluginName, 'Warning: "POST" dohMethod is going to be ignored as dohMediaType is not "application/dns-message"') + } + } + // reality check: dohProvider must be a string if (typeof(config.dohProvider) !== "string" || (config.dohProvider == '')) { let err = new Error("dohProvider not confgured") console.error(err) throw err } - + + // externally defined functions + if (typeof self.importScripts !== 'undefined') { + LR.log(pluginName, 'importing doh.js') + self.importScripts("./lib/doh.js") + } + + // check if doh.js was properly loaded + if (typeof resolveEndpointsBinary !== "function") { + throw new Error("resolveEndpointsBinary() is not defined, is doh.js loaded?") + } + if (typeof resolveEndpointsJSON !== "function") { + throw new Error("resolveEndpointsJSON() is not defined, is doh.js loaded?") + } + /** * retrieving the alternative endpoints list from dnslink + * using DNS-over-HTTPS * - * returns an array of strings, each being a valid endpoint, in the form of + * returns an array of strings, each being a valid endpoint, + * in the form of * scheme://example.org[/optional/path] */ let resolveEndpoints = async (domain) => { - // pretty self-explanatory: - // DoH provider, _dnslink label in the domain, TXT type, pretty please - var query = `${config.dohProvider}?name=_dnslink.${domain}&type=TXT` - - // do we want to mask the EDNS Client Subnet? + // first, we need the actual name we're asking about // - // this protects user privacy somewhat by telling the DoH provider not to disclose - // the subnet from which the DNS request came to authoritiative nameservers - if (config.ecsMasked) { - query += '&edns_client_subnet=0.0.0.0/0' - } + // this needs to be ASCII-encoded, so if we want an IDN domain + // it needs to be punycode-encoded! + // TODO: we should *probably* do that ourselves here... + let dnslink_name = `_dnslink.${domain}` - // make the query, get the response - var response = await fetch( - query, { - headers: { - 'accept': 'application/json', - } - }) - .then(r=>r.json()) - - // we need an object here - if (typeof response !== 'object') { - throw new Error('Response is not a valid JSON') - } + // response we will need to process a bit + let response = [] - // only Status == 0 is acceptable - // https://www.iana.org/assignments/dns-parameters/dns-parameters.xhtml#dns-parameters-6 - if (!('Status' in response) || response.Status != 0) { - throw new Error(`DNS request failure, status code: ${response.Status}`) - } + // we can either resolve them using the binary DoH DNS wire format request... + if (config.dohMediaType === 'application/dns-message') { + // defined in doh.js + response = await resolveEndpointsBinary(dnslink_name, config, (msg)=>{LR.log(pluginName, msg)}) - // we also do need the Answer section please - if (!('Answer' in response) || (typeof response.Answer !== 'object') || (!Array.isArray(response.Answer))) { - throw new Error(`DNS response did not contain a valid Answer section`) - } - - // only get TXT records, and extract the data from them - response = response - .Answer - .filter(r => r.type == 16) - .map(r => r.data); - - // did we get anything of value? anything at all? - if (response.length < 1) { - throw new Error(`Answer section of the DNS response did not contain any TXT records`) + // ...or a DoH JSON API request + } else { + // defined in doh.js + response = await resolveEndpointsJSON(dnslink_name, config) } // filter by 'dnslink="/https?/', morph into scheme://... @@ -126,7 +169,7 @@ } // in case we need some debugging - LR.log(pluginName, '+-- alternative endpoints from DNSLink:\n - ', response.join('\n - ')) + LR.log(pluginName, '+-- alternative endpoints from DNSLink:\n - ' + response.join('"\n - ')) // this should be what we're looking for - an array of URLs return response