dohjs
Version:
DNS over HTTPS lookups from web apps
292 lines (271 loc) • 9.28 kB
JavaScript
const dnsPacket = require('dns-packet');
const https = require('https');
const http = require('http');
const base32Encode = require('base32-encode');
var URL = require('url').URL;
if (typeof window !== "undefined") {
URL = window.URL;
}
/**
* Allowed request methods for sending DNS over HTTPS requests.
* <br>
* Allowed method are "GET" and "POST"
* @type {array}
*/
const ALLOWED_REQUEST_METHODS = ["GET", "POST"];
/**
* Custom error class to be thrown when someone tries to send a DoH request
* with a request method other than "GET" or "POST"
*/
class MethodNotAllowedError extends Error {
constructor(message = "", ...params) {
super();
this.name = 'MethodNotAllowedError';
this.message = message;
}
}
/**
* Check if a request method is allowed
* @param method {string} the request method to test
* @returns {boolean} If `method` is "GET" or "POST", return true; return false otherwise.
*/
function isMethodAllowed(method) {
return ALLOWED_REQUEST_METHODS.indexOf(method) !== -1;
}
/**
* A super lame DNS over HTTPS stub resolver
*/
class DohResolver {
/**
* Creates a new DoH resolver
* @param nameserver_url {string} The URL we're going to be sending DNS requests to
* @example
// import the required stuff
const {DohResolver} = require('dohjs');
// create your resolver
const resolver = new DohResolver('https://dns.google/dns-query')
// lookup the A records for example.com
// print out the answer data
resolver.query('example.com', 'A')
.then(response => {
response.answer.forEach(ans => console.log(ans.data));
})
.catch(err => console.error(err));
*/
constructor(nameserver_url) {
this.nameserver_url = nameserver_url;
}
/**
* Perform a DNS lookup for the given query name and type.
*
* @param qname {string} the domain name to query for (e.g. example.com)
* @param qtype {string} the type of record we're looking for (e.g. A, AAAA, TXT, MX)
* @param method {string} Must be either "GET" or "POST"
* @param headers {object} define HTTP headers to use in the DNS query
* <br>
* <b><i>IMPORTANT: If you don't provide the "Accept: application/dns-message" header, you probably won't get the response you're hoping for.
* See [RFC 8484 examples](https://tools.ietf.org/html/rfc8484#section-4.1.1) for examples of HTTPS headers for both GET and POST requests.</i></b>
* @param timeout {number} the number of milliseconds to wait for a response before aborting the request
* @throws {MethodNotAllowedError} If the method is not allowed (i.e. if it's not "GET" or "POST"), a MethodNotAllowedError will be thrown.
* @returns {Promise<object>} The DNS response received
*/
query(qname, qtype='A', method='POST', headers=null, timeout=null) {
return new Promise((resolve, reject) => {
if (!isMethodAllowed(method)) {
return reject(new MethodNotAllowedError(`Request method ${method} not allowed. Must be either 'GET' or 'POST'`))
}
let dnsMessage = makeQuery(qname, qtype);
sendDohMsg(dnsMessage, this.nameserver_url, method, headers, timeout)
.then(resolve)
.catch(reject)
});
}
}
/**
* Make a DNS query message of type {@link object} (see [dns-packet]{@link https://github.com/mafintosh/dns-packet}). Use this before calling {@link sendDohMsg}
* <br>
* The recursion desired flag will be set, and the ID in the header will be set to zero, per the RFC ([section 4.1](https://tools.ietf.org/html/rfc8484#section-4.1)).
* @param qname {string} the domain name to put in the query message (e.g. example.com)
* @param qtype {string} the query type to put in the query message (e.g. A, AAAA, DS, DNSKEY)
* @returns {object} The DNS query message
* @example
// imports
const {makeQuery} = require('dohjs');
// create a query message
const msg = makeQuery('example.com', 'TXT');
// print it out to the console
console.log(msg);
// -> { type: 'query',
// -> id: 0,
// -> flags: 256,
// -> questions: [ { type: 'TXT', name: 'example.com' } ] }
*/
function makeQuery(qname, qtype='A') {
return {
type: 'query',
/*
In order to maximize HTTP cache friendliness, DoH clients using media
formats that include the ID field from the DNS message header, such
as "application/dns-message", SHOULD use a DNS ID of 0 in every DNS
request.
https://tools.ietf.org/html/rfc8484#section-4.1
*/
id: 0,
flags: dnsPacket.RECURSION_DESIRED,
questions: [{
type: qtype,
name: qname,
}]
};
}
/**
* Send a DNS message over HTTPS to `url` using the given request method
*
* @param packet {object} the DNS message to send
* @param url {string} the url to send the DNS message to
* @param method {string} the request method to use ("GET" or "POST")
* @param headers {object} headers to send in the DNS request. The default headers for GET requests are
* @param timeout {number} the number of milliseconds to wait for a response before aborting the request
* @returns {Promise<object>} the response (if we got any)
* @example
// imports
const {makeQuery, sendDohMsg} = require('dohjs');
const url = 'https://cloudflare-dns.com/dns-query';
const method = 'GET';
// create a query message
let msg = makeQuery('example.com', 'TXT');
// send it and print out the response to the console
sendDohMsg(msg, url, method)
.then(response => response.answers.forEach(ans => console.log(ans.data.toString())))
.catch(console.error);
*/
function sendDohMsg(packet, url, method, headers, timeout) {
return new Promise((resolve, reject) => {
const transport = url.startsWith('https://') ? https : http;
const buf = dnsPacket.encode(packet);
let requestOptions;
if (!headers) {
headers = {
'Accept': 'application/dns-message',
'User-Agent': 'dohjs/0.2.0'
};
}
if (method === 'POST') {
Object.assign(headers, {
'Content-Type': 'application/dns-message',
'Content-Length': buf.length
});
} else if (method === 'GET') {
const dnsQueryParam = buf.toString('base64').toString('utf-8').replace(/=/g, '');
url = `${url}?dns=${dnsQueryParam}`;
}
url = new URL(url);
requestOptions = {
method: method,
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
headers: headers
};
let data;
let timer;
const request = transport.request(requestOptions, (response) => {
response.on('data', (d) => {
if (!data) {
data = d;
} else {
data = Buffer.concat([data, d]);
}
});
response.on('end', () => {
if (timer) {
clearTimeout(timer);
}
try {
const decoded = dnsPacket.decode(data);
resolve(decoded);
} catch (e) {
reject(e);
}
});
});
request.on('error', (err) => {
request.destroy();
return reject(err);
});
if (timeout) {
timer = setTimeout(() => {
request.destroy();
return reject(new Error(`Query timed out after ${timeout} milliseconds of inactivity`));
}, timeout);
}
if (method === 'POST') {
request.write(buf)
}
request.end()
});
}
/**
* 'Prettifies' a dnsPacket message.
*
* Namely, this convert Buffers the the appropriate presentation format.
* This is useful to make json human readable.
*
* *NOTE* This function may modify the message such that it no longer works with dnsPacket.
* Caution should be used when calling this object on query packets before sending them
*
* @param msg {object} a dnsPacket
* @returns {object} the msg which has been modified in-place. *May not be a valid <dnsPacket> afterwards*
*/
function prettify(msg) {
for (const rr of (msg['answers'] || []).concat((msg['authorities'] || []))) {
if (rr.hasOwnProperty('data')) {
switch (rr.type) {
case 'TXT':
rr.data = rr.data.toString('utf8');
break;
case 'DNSKEY':
rr.data.key = rr.data.key.toString('base64').replace('=', '');
break;
case 'DS':
rr.data.digest = rr.data.digest.toString('hex');
break;
case 'NSEC3':
rr.data.salt = rr.data.salt.toString('hex');
rr.data.nextDomain = base32Encode(rr.data.nextDomain, 'RFC4648-HEX').replace('=', '')
break;
case 'RRSIG':
rr.data.signature = rr.data.signature.toString('base64').replace('=', '');
break;
}
}
}
for (const rr of (msg['additionals'] || [])) {
if (rr.type === 'OPT') {
for (const opt of rr['options']) {
switch(opt.code) {
case 12:
opt.length = opt.data.length;
opt.data = opt.data.toString('hex').substring(0, 80);
if (opt.data.length === 80 ) {
opt.data += '...'
}
break;
}
}
}
}
return msg
}
const dohjs = {
/* dohjs exports */
sendDohMsg: sendDohMsg,
DohResolver: DohResolver,
makeQuery: makeQuery,
MethodNotAllowedError: MethodNotAllowedError,
isMethodAllowed: isMethodAllowed,
prettify: prettify,
/* expose dnsPacket as part of dohjs */
dnsPacket: dnsPacket
};
module.exports = dohjs;