From c769a95f6558d7a5c18577d715fbb1251620c3a3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 4 Nov 2023 15:21:47 -0400 Subject: [PATCH] API: Add endpoint to check custom domain resolution (#6110) --- server/routes/api/urls/schema.ts | 8 +++ server/routes/api/urls/urls.test.ts | 37 +++++++++++++ server/routes/api/urls/urls.ts | 64 +++++++++++++++++++++- shared/i18n/locales/en_US/translation.json | 1 + 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/server/routes/api/urls/schema.ts b/server/routes/api/urls/schema.ts index d531e427b..f9ecf4866 100644 --- a/server/routes/api/urls/schema.ts +++ b/server/routes/api/urls/schema.ts @@ -34,3 +34,11 @@ export const UrlsUnfurlSchema = BaseSchema.extend({ }); export type UrlsUnfurlReq = z.infer; + +export const UrlsCheckCnameSchema = BaseSchema.extend({ + body: z.object({ + hostname: z.string(), + }), +}); + +export type UrlsCheckCnameReq = z.infer; diff --git a/server/routes/api/urls/urls.test.ts b/server/routes/api/urls/urls.test.ts index e7495b93f..69d3c9377 100644 --- a/server/routes/api/urls/urls.test.ts +++ b/server/routes/api/urls/urls.test.ts @@ -4,6 +4,19 @@ import { buildDocument, buildUser } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; import resolvers from "@server/utils/unfurl"; +jest.mock("dns", () => ({ + resolveCname: ( + input: string, + callback: (err: Error | null, addresses: string[]) => void + ) => { + if (input.includes("valid.custom.domain")) { + callback(null, ["secure.outline.dev"]); + } else { + callback(null, []); + } + }, +})); + jest .spyOn(resolvers.Iframely, "unfurl") .mockImplementation(async (_: string) => false); @@ -204,3 +217,27 @@ describe("#urls.unfurl", () => { expect(res.status).toEqual(204); }); }); + +describe("#urls.validateCustomDomain", () => { + it("should succeed with custom domain pointing at server", async () => { + const user = await buildUser(); + const res = await server.post("/api/urls.validateCustomDomain", { + body: { + token: user.getJwtToken(), + hostname: "valid.custom.domain", + }, + }); + expect(res.status).toEqual(200); + }); + + it("should fail with another domain", async () => { + const user = await buildUser(); + const res = await server.post("/api/urls.validateCustomDomain", { + body: { + token: user.getJwtToken(), + hostname: "google.com", + }, + }); + expect(res.status).toEqual(400); + }); +}); diff --git a/server/routes/api/urls/urls.ts b/server/routes/api/urls/urls.ts index 00154a119..25b7336be 100644 --- a/server/routes/api/urls/urls.ts +++ b/server/routes/api/urls/urls.ts @@ -1,5 +1,6 @@ +import dns from "dns"; import Router from "koa-router"; -import { parseDomain } from "@shared/utils/domains"; +import { getBaseDomain, parseDomain } from "@shared/utils/domains"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import parseMentionUrl from "@shared/utils/parseMentionUrl"; import { isInternalUrl } from "@shared/utils/urls"; @@ -7,7 +8,7 @@ import { NotFoundError, ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import validate from "@server/middlewares/validate"; -import { Document, User } from "@server/models"; +import { Document, Share, Team, User } from "@server/models"; import { authorize } from "@server/policies"; import { presentDocument, presentMention } from "@server/presenters/unfurls"; import presentUnfurl from "@server/presenters/unfurls/unfurl"; @@ -84,4 +85,63 @@ router.post( } ); +router.post( + "urls.validateCustomDomain", + rateLimiter(RateLimiterStrategy.OneHundredPerHour), + auth(), + validate(T.UrlsCheckCnameSchema), + async (ctx: APIContext) => { + const { hostname } = ctx.input.body; + + const [team, share] = await Promise.all([ + Team.findOne({ + where: { + domain: hostname, + }, + }), + Share.findOne({ + where: { + domain: hostname, + }, + }), + ]); + if (team || share) { + throw ValidationError("Domain is already in use"); + } + + let addresses; + try { + addresses = await new Promise((resolve, reject) => { + dns.resolveCname(hostname, (err, addresses) => { + if (err) { + return reject(err); + } + return resolve(addresses); + }); + }); + } catch (err) { + if (err.code === "ENOTFOUND") { + throw NotFoundError("No CNAME record found"); + } + + throw ValidationError("Invalid domain"); + } + + if (addresses.length === 0) { + throw ValidationError("No CNAME record found"); + } + + const address = addresses[0]; + const likelyValid = address.endsWith(getBaseDomain()); + + if (!likelyValid) { + throw ValidationError("CNAME is not configured correctly"); + } + + ctx.body = { + success: true, + }; + } +); + export default router; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index a3fb56f09..287f4c9dc 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -573,6 +573,7 @@ "Custom link": "Custom link", "The document will be accessible at <2>{{url}}": "The document will be accessible at <2>{{url}}", "More options": "More options", + "Custom domain": "Custom domain", "Close": "Close", "{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.", "Are you sure you want to delete the {{ documentTitle }} template?": "Are you sure you want to delete the {{ documentTitle }} template?",