diff --git a/__tests__/__snapshots__/index.spec.ts.snap b/__tests__/__snapshots__/index.spec.ts.snap index abb76d1..5000c98 100644 --- a/__tests__/__snapshots__/index.spec.ts.snap +++ b/__tests__/__snapshots__/index.spec.ts.snap @@ -1,7 +1,15 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`#getLinkPreview() no link in text should fail gracefully 1`] = `"link-preview-js did not receive a valid a url or text"`; +exports[`#getLinkPreview() should block .internal hostnames 1`] = `"link-preview-js did not receive a valid a url or text"`; + +exports[`#getLinkPreview() should block .local hostnames 1`] = `"link-preview-js did not receive a valid a url or text"`; + +exports[`#getLinkPreview() should block nip.io wildcard hostnames 1`] = `"link-preview-js did not receive a valid a url or text"`; + +exports[`#getLinkPreview() should block sslip.io wildcard hostnames 1`] = `"link-preview-js did not receive a valid a url or text"`; + exports[`#getLinkPreview() should handle empty strings gracefully 1`] = `"link-preview-js did not receive a valid url or text"`; exports[`#getLinkPreview() should handle malformed urls gracefully 1`] = `"link-preview-js did not receive a valid a url or text"`; diff --git a/__tests__/index.spec.ts b/__tests__/index.spec.ts index 4545034..4bee4b5 100644 --- a/__tests__/index.spec.ts +++ b/__tests__/index.spec.ts @@ -1,6 +1,22 @@ import { getLinkPreview, getPreviewFromContent } from "../index"; +import { CONSTANTS } from "../constants"; import prefetchedResponse from "./sampleResponse.json"; +describe(`#REGEX_LOOPBACK`, () => { + it(`matches IPv6 loopback and local ranges`, () => { + expect(CONSTANTS.REGEX_LOOPBACK.test(`::1`)).toBe(true); + expect(CONSTANTS.REGEX_LOOPBACK.test(`::ffff:127.0.0.1`)).toBe(true); + expect(CONSTANTS.REGEX_LOOPBACK.test(`fc00::1`)).toBe(true); + expect(CONSTANTS.REGEX_LOOPBACK.test(`fd12:3456:789a::1`)).toBe(true); + expect(CONSTANTS.REGEX_LOOPBACK.test(`fe80::abcd`)).toBe(true); + expect(CONSTANTS.REGEX_LOOPBACK.test(`febf::abcd`)).toBe(true); + }); + + it(`does not match non-local IPv6 addresses`, () => { + expect(CONSTANTS.REGEX_LOOPBACK.test(`2001:4860:4860::8888`)).toBe(false); + }); +}); + describe(`#getLinkPreview()`, () => { it(`should extract link info from just URL`, async () => { const linkInfo: any = await getLinkPreview(`https://www.youtube.com/watch?v=wuClZjOdT30`, { @@ -150,6 +166,32 @@ describe(`#getLinkPreview()`, () => { ).rejects.toThrowErrorMatchingSnapshot(); }); + it(`should block .internal hostnames`, async () => { + await expect( + getLinkPreview( + `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token`, + ), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it(`should block .local hostnames`, async () => { + await expect( + getLinkPreview(`http://printer.local/status`), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it(`should block nip.io wildcard hostnames`, async () => { + await expect( + getLinkPreview(`http://169.254.169.254.nip.io/latest/meta-data/iam/security-credentials/`), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + it(`should block sslip.io wildcard hostnames`, async () => { + await expect( + getLinkPreview(`http://127.0.0.1.sslip.io/`), + ).rejects.toThrowErrorMatchingSnapshot(); + }); + it(`should handle empty strings gracefully`, async () => { await expect(getLinkPreview(``)).rejects.toThrowErrorMatchingSnapshot(); }); diff --git a/constants.ts b/constants.ts index f747ab3..fb5d256 100644 --- a/constants.ts +++ b/constants.ts @@ -5,6 +5,9 @@ export const CONSTANTS = { "(?:(?:https?|ftp)://)" + // user:pass authentication "(?:\\S+(?::\\S*)?@)?" + + // block internal-only hostnames and wildcard DNS rebinding services + "(?![^/?#]+\\.(?:internal|local)(?::\\d{2,5})?(?:[/?#]|$))" + + "(?![^/?#]+\\.(?:nip|sslip)\\.io(?::\\d{2,5})?(?:[/?#]|$))" + "(?:" + // IP address exclusion // private & local networks @@ -34,7 +37,7 @@ export const CONSTANTS = { // resource path "(?:[/?#]\\S*)?" + "$", - "i" + "i", ), REGEX_LOOPBACK: new RegExp( @@ -63,8 +66,20 @@ export const CONSTANTS = { "|" + // Carrier-Grade NAT (CGNAT): 100.64.0.0 - 100.127.255.255 "(?:100\\.(?:6[4-9]|[7-9]\\d|1[0-1]\\d)(?:\\.\\d{1,3}){2})" + + "|" + + // IPv6 loopback + "(?:::1)" + + "|" + + // IPv4-mapped IPv6 loopback: ::ffff:127.0.0.0/104 + "(?:::ffff:127(?:\\.\\d{1,3}){3})" + + "|" + + // IPv6 Unique Local Address (ULA): fc00::/7 + "(?:f[c-d][0-9a-f]{2}:[0-9a-f:]+)" + + "|" + + // IPv6 link-local unicast: fe80::/10 + "(?:fe[89ab][0-9a-f]:[0-9a-f:]+)" + "$", - "i" + "i", ), REGEX_CONTENT_TYPE_IMAGE: new RegExp("image/.*", "i"), diff --git a/index.ts b/index.ts index eb3579d..e004b58 100644 --- a/index.ts +++ b/index.ts @@ -413,6 +413,10 @@ export async function getLinkPreview(text: string, options?: ILinkPreviewOptions const resolvedUrl = await options.resolveDNSHost(detectedUrl); throwOnLoopback(resolvedUrl); + } else { + console.error( + "[link-preview-js] You are not resolving DNS addresses (resolveDNSHost option) before fetching a link. This can cause loopback attacks. Always try to resolve DNS addresses", + ); } const timeout = options?.timeout ?? 3000; // 3 second timeout default