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
10 changes: 9 additions & 1 deletion __tests__/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -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"`;
42 changes: 42 additions & 0 deletions __tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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`, {
Expand Down Expand Up @@ -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();
});
Expand Down
19 changes: 17 additions & 2 deletions constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,7 +37,7 @@ export const CONSTANTS = {
// resource path
"(?:[/?#]\\S*)?" +
"$",
"i"
"i",
),

REGEX_LOOPBACK: new RegExp(
Expand Down Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading