Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/polite-lamps-jam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nodesecure/tarball": minor
---

refactor(tarball): improve dns resolver
38 changes: 33 additions & 5 deletions workspaces/tarball/src/class/DnsResolver.class.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Import Node.js Dependencies
import { lookup } from "node:dns/promises";
import { type LookupAddress } from "node:dns";
import { resolve4, resolve6 } from "node:dns/promises";

// Import Third-party Dependencies
import ipaddress from "ipaddr.js";
Expand All @@ -9,12 +8,41 @@ export interface Resolver {
isPrivateHost(hostname: string): Promise<boolean>;
}

export type Lookup = (hostname: string) => Promise<string[]>;

async function lookupAll(hostname: string) {
const ips = await Promise.allSettled([
resolve4(hostname),
resolve6(hostname)
]);

const ipv4 = ips[0].status === "fulfilled"
? ips[0].value
: [];

const ipv6 = ips[1].status === "fulfilled"
? ips[1].value
: [];

return [...ipv4, ...ipv6];
}

export class DnsResolver implements Resolver {
#memo: Map<string, boolean> = new Map();
#lookup: Lookup;
constructor(lookup?: Lookup) {
this.#lookup = lookup ?? lookupAll;
}
async isPrivateHost(hostname: string) {
const ipAddressListDetails: LookupAddress[] = await lookup(hostname, { all: true });
const ipAddressList = ipAddressListDetails.map((ipAddressDetails) => ipAddressDetails.address);
if (this.#memo.has(hostname)) {
return this.#memo.get(hostname)!;
}
const ipAddressList = await this.#lookup(hostname);

const isPrivate = ipAddressList.some(this.#isPrivateIPAddress);
this.#memo.set(hostname, isPrivate);

return ipAddressList.some(this.#isPrivateIPAddress);
return isPrivate;
}

#isPrivateIPAddress(ipAddress: string): boolean {
Expand Down
123 changes: 123 additions & 0 deletions workspaces/tarball/test/DnsResovler.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Import Node.js Dependencies
import assert from "node:assert";
import { describe, it } from "node:test";

// Import Internal Dependencies
import { DnsResolver } from "../src/class/DnsResolver.class.ts";

describe("DnsResolver", () => {
describe("IPv4", () => {
it("should be a private host for a private IPv4 address (10.x.x.x)", async() => {
const resolver = new DnsResolver(makeLookup(["10.0.0.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private addr for a private IPv4 address (192.168.x.x)", async() => {
const resolver = new DnsResolver(makeLookup(["192.168.1.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private host IPv4 address (172.16.x.x)", async() => {
const resolver = new DnsResolver(makeLookup(["172.16.0.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private host for loopback IPv4 (127.0.0.1)", async() => {
const resolver = new DnsResolver(makeLookup(["127.0.0.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a public host for a public IPv4 address", async() => {
const resolver = new DnsResolver(makeLookup(["8.8.8.8"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});

it("should be a private host when mixed addresses contain at least one private IPv4", async() => {
const resolver = new DnsResolver(makeLookup(["8.8.8.8", "192.168.1.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a public host when all addresses are public IPv4", async() => {
const resolver = new DnsResolver(makeLookup(["8.8.8.8", "1.1.1.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});
});

describe("IPv6", () => {
it("should be a private host loopback IPv6 (::1)", async() => {
const resolver = new DnsResolver(makeLookup(["::1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private host for a private IPv4-mapped IPv6 address (::ffff:192.168.1.1)", async() => {
const resolver = new DnsResolver(makeLookup(["::ffff:192.168.1.1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a public host for a public IPv4-mapped IPv6 address (::ffff:8.8.8.8)", async() => {
const resolver = new DnsResolver(makeLookup(["::ffff:8.8.8.8"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});

it("should be a private host for unique local IPv6 address (fc00::/7)", async() => {
const resolver = new DnsResolver(makeLookup(["fc00::1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private host for link-local IPv6 address (fe80::)", async() => {
const resolver = new DnsResolver(makeLookup(["fe80::1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a public host for a public IPv6 address", async() => {
const resolver = new DnsResolver(makeLookup(["2606:4700:4700::1111"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});

it("should be a private host when mixed IPv6 addresses contain at least one private", async() => {
const resolver = new DnsResolver(makeLookup(["2606:4700:4700::1111", "::1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});
});

describe("mixed IPv4 and IPv6", () => {
it("should be a public host when all addresses (v4 and v6) are public", async() => {
const resolver = new DnsResolver(makeLookup(["8.8.8.8", "2606:4700:4700::1111"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});

it("should be a private host when IPv4 is private and IPv6 is public", async() => {
const resolver = new DnsResolver(makeLookup(["192.168.0.1", "2606:4700:4700::1111"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});

it("should be a private host when IPv4 is public and IPv6 is private", async() => {
const resolver = new DnsResolver(makeLookup(["8.8.8.8", "fc00::1"], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), true);
});
});

it("should be a public host when the address list is empty", async() => {
const resolver = new DnsResolver(makeLookup([], "fake.host"));
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
});

it("should cache the result of the lookups", async(t) => {
const lookupMock = t.mock.fn<(hostname: string) => Promise<string[]>>();
lookupMock.mock.mockImplementation(async() => ["8.8.8.8"]);
const resolver = new DnsResolver(lookupMock);
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
assert.strictEqual(await resolver.isPrivateHost("fake.host"), false);
assert.deepEqual(lookupMock.mock.callCount(), 1);
});
});

function makeLookup(addresses: string[], expectedHostname?: string) {
return (hostname: string) => {
if (expectedHostname !== undefined) {
assert.strictEqual(hostname, expectedHostname);
}

return Promise.resolve(addresses);
};
}
Loading