Skip to content

Commit

Permalink
optimized link cloaking
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey committed Sep 3, 2023
1 parent 359cf84 commit 594163c
Show file tree
Hide file tree
Showing 3 changed files with 45 additions and 30 deletions.
16 changes: 10 additions & 6 deletions lib/api/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { redis } from "#/lib/upstash";
import { getParamsFromURL, nanoid, truncate, validKeyRegex } from "#/lib/utils";
import { isReservedKey } from "#/lib/edge-config";
import { NextApiRequest } from "next";
import { hasXFrameOptions } from "../middleware/utils";
import { isIframeable } from "../middleware/utils";

export async function getLinksForProject({
projectId,
Expand Down Expand Up @@ -214,7 +214,7 @@ export async function addLink(link: LinkProps) {
const { utm_source, utm_medium, utm_campaign, utm_term, utm_content } =
getParamsFromURL(url);

let [response, _, __] = await Promise.all([
let [response, _] = await Promise.all([
prisma.link.create({
data: {
...link,
Expand All @@ -236,7 +236,10 @@ export async function addLink(link: LinkProps) {
url: encodeURIComponent(url),
password: hasPassword,
proxy,
...(rewrite && { rewrite: true }),
...(rewrite && {
rewrite: true,
iframeable: await isIframeable({ url, requestDomain: domain }),
}),
...(ios && { ios }),
...(android && { android }),
...(geo && { geo }),
Expand All @@ -247,7 +250,6 @@ export async function addLink(link: LinkProps) {
...(exat && { exat: exat as any }),
},
),
rewrite && hasXFrameOptions(url),
]);
if (proxy && image) {
const { secure_url } = await cloudinary.v2.uploader.upload(image, {
Expand Down Expand Up @@ -343,14 +345,16 @@ export async function editLink(
url: encodeURIComponent(url),
password: hasPassword,
proxy,
...(rewrite && { rewrite: true }),
...(rewrite && {
rewrite: true,
iframeable: await isIframeable({ url, requestDomain: domain }),
}),
...(ios && { ios }),
...(android && { android }),
...(geo && { geo }),
},
exat ? { exat } : {},
),
rewrite && hasXFrameOptions(url),
// if key is changed: rename resource in Cloudinary, delete the old key in Redis and change the clicks key name
...(changedDomain || changedKey
? [
Expand Down
19 changes: 7 additions & 12 deletions lib/middleware/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,7 @@ import {
NextResponse,
userAgent,
} from "next/server";
import {
detectBot,
getFinalUrl,
hasXFrameOptions,
parse,
} from "#/lib/middleware/utils";
import { detectBot, getFinalUrl, parse } from "#/lib/middleware/utils";
import { ratelimit, redis } from "#/lib/upstash";
import { recordClick } from "#/lib/tinybird";
import { DUB_HEADERS, LOCALHOST_GEO_DATA, LOCALHOST_IP } from "../constants";
Expand Down Expand Up @@ -51,6 +46,7 @@ export default async function LinkMiddleware(
password?: boolean;
proxy?: boolean;
rewrite?: boolean;
iframeable?: boolean;
ios?: string;
android?: string;
geo?: object;
Expand All @@ -64,6 +60,7 @@ export default async function LinkMiddleware(
password,
proxy,
rewrite,
iframeable,
ios,
android,
geo,
Expand Down Expand Up @@ -96,16 +93,14 @@ export default async function LinkMiddleware(

// rewrite to target URL if link cloaking is enabled
if (rewrite) {
// check if there's a `X-Frame-Options` header
const xFrameOptions = await hasXFrameOptions(decodeURIComponent(target));
if (xFrameOptions) {
// if there is, use Next.js rewrite instead of iframe
return NextResponse.rewrite(decodeURIComponent(target), DUB_HEADERS);
} else {
if (iframeable) {
return NextResponse.rewrite(
new URL(`/rewrite/${target}`, req.url),
DUB_HEADERS,
);
} else {
// if link is not iframeable, use Next.js rewrite instead
return NextResponse.rewrite(decodeURIComponent(target), DUB_HEADERS);
}

// rewrite to proxy page (/_proxy/[domain]/[key]) if it's a bot and proxy is enabled
Expand Down
40 changes: 28 additions & 12 deletions lib/middleware/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,23 +62,39 @@ export const detectBot = (req: NextRequest) => {
return false;
};

// check if a URL has a `X-Frame-Options` header (and caches it in Redis)
export const hasXFrameOptions = async (url: string) => {
const cachedResults = await redis.get(`x-frame-options:${url}`);
if (cachedResults) {
return cachedResults === "yes";
}

// check if a link can be displayed in an iframe
export const isIframeable = async ({
url,
requestDomain,
}: {
url: string;
requestDomain: string;
}) => {
const res = await fetch(url, {
headers: {
"User-Agent": "dub-bot/1.0",
},
});

const xFrameOptions = res.headers.get("X-Frame-Options"); // returns null if there is no `X-Frame-Options` header
// cache for 1 month
await redis.set(`x-frame-options:${url}`, xFrameOptions ? "yes" : "no", {
ex: 60 * 60 * 24 * 30,
});
if (xFrameOptions) {
return false;
}

return xFrameOptions;
const cspHeader = res.headers.get("content-security-policy");
if (!cspHeader) {
return true;
}

const frameAncestorsMatch = cspHeader.match(
/frame-ancestors\s+([\s\S]+?)(?=;|$)/i,
);
if (frameAncestorsMatch) {
const allowedOrigins = frameAncestorsMatch[1].split(/\s+/);
if (allowedOrigins.includes(requestDomain)) {
return true;
}
}

return false;
};

0 comments on commit 594163c

Please sign in to comment.