Files
outline/server/routes/api/urls/urls.ts
T
Tom Moor 879d2b8198 fix: Allow connecting additional auth providers on custom domain (#12364)
* fix: Unable to link secondary auth provider on custom domain

* doc

* chore: Custom -> Apex transfer token

* Refactor, address security concerns

* Ensure OAuth intent is single-use

* Secure OAuth state actor binding

* Use scrypt for OAuth actor session binding
2026-05-16 19:56:21 -04:00

306 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dns from "node:dns";
import Router from "koa-router";
import { traceFunction } from "@server/logging/tracing";
import isUUID from "validator/lib/isUUID";
import { MentionType, UnfurlResourceType } from "@shared/types";
import { getBaseDomain, parseDomain } from "@shared/utils/domains";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import parseMentionUrl from "@shared/utils/parseMentionUrl";
import { isInternalUrl, parseShareIdFromUrl } from "@shared/utils/urls";
import {
AuthenticationError,
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, Share, Team, User, Group, GroupUser } from "@server/models";
import { authorize, can } from "@server/policies";
import { loadPublicShare } from "@server/commands/shareLoader";
import presentUnfurl from "@server/presenters/unfurl";
import type { APIContext, Unfurl } from "@server/types";
import { CacheHelper, type CacheResult } from "@server/utils/CacheHelper";
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
import { Hook, PluginManager } from "@server/utils/PluginManager";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import {
checkEmbeddability,
type EmbedCheckResult,
} from "@server/utils/embeds";
import { getTeamFromContext } from "@server/utils/passport";
import * as T from "./schema";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import { Day } from "@shared/utils/time";
const router = new Router();
const plugins = PluginManager.getHooks(Hook.UnfurlProvider);
router.post(
"urls.unfurl",
rateLimiter(RateLimiterStrategy.OneThousandPerHour),
auth({ optional: true }),
validate(T.UrlsUnfurlSchema),
async (ctx: APIContext<T.UrlsUnfurlReq>) => {
const { url, documentId } = ctx.input.body;
const urlObj = new URL(url);
// Public share URLs does not require authentication
if (isInternalUrl(url)) {
const shareId = parseShareIdFromUrl(url);
if (shareId) {
const actor = ctx.state.auth.user;
// teamId is only needed when the share identifier is a slug, not a UUID
let teamId: string | undefined = actor?.teamId;
if (!teamId && !isUUID(shareId)) {
const teamFromCtx = await getTeamFromContext(ctx, {
includeOAuthState: false,
});
teamId = teamFromCtx?.id;
}
const previewDocumentId = parseDocumentSlug(url);
const { share, document } = await loadPublicShare({
id: shareId,
documentId: previewDocumentId,
teamId,
});
if (!document) {
ctx.response.status = 204;
return;
}
ctx.body = await presentUnfurl({
type: UnfurlResourceType.Document,
document,
viewer: actor,
url: `${share.canonicalUrl}/doc/${document.url.replace("/doc/", "")}`,
});
return;
}
}
// Everything below requires authentication
const { user: actor } = ctx.state.auth;
if (!actor) {
throw AuthenticationError();
}
// Mentions
if (urlObj.protocol === "mention:") {
if (!documentId) {
throw ValidationError("Document ID is required to unfurl a mention");
}
const { modelId, mentionType } = parseMentionUrl(url);
if (mentionType === MentionType.User) {
const [user, document] = await Promise.all([
User.findByPk(modelId),
Document.findByPk(documentId, {
userId: actor.id,
}),
]);
if (!user) {
throw NotFoundError("Mentioned user does not exist");
}
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", user);
authorize(actor, "read", document);
ctx.body = await presentUnfurl(
{
type: UnfurlResourceType.Mention,
user,
document,
},
{ includeEmail: !!can(actor, "readEmail", user) }
);
} else if (mentionType === MentionType.Group) {
const [group, document] = await Promise.all([
Group.findByPk(modelId),
Document.findByPk(documentId, {
userId: actor.id,
}),
]);
if (!group) {
throw NotFoundError("Mentioned group does not exist");
}
if (!document) {
throw NotFoundError("Document does not exist");
}
authorize(actor, "read", group);
authorize(actor, "read", document);
// Get group members for display
const groupUsers = await GroupUser.findAll({
where: { groupId: group.id },
include: [
{
model: User,
as: "user",
},
],
limit: MAX_AVATAR_DISPLAY,
});
const users = groupUsers.map((gu) => gu.user).filter(Boolean);
ctx.body = await presentUnfurl({
type: UnfurlResourceType.Group,
group,
users,
});
}
return;
}
// Internal resources
if (isInternalUrl(url) || parseDomain(url).host === actor.team.domain) {
const previewDocumentId = parseDocumentSlug(url);
if (previewDocumentId) {
const document = await Document.findByPk(previewDocumentId, {
userId: actor.id,
});
if (!document || !can(actor, "read", document)) {
ctx.response.status = 204;
return;
}
ctx.body = await presentUnfurl({
type: UnfurlResourceType.Document,
document,
viewer: actor,
});
return;
}
ctx.response.status = 204;
return;
}
// External resources
// Use getDataOrSet which handles distributed locking to prevent thundering herd
// when multiple clients request the same URL simultaneously
const cacheKey = RedisPrefixHelper.getUnfurlKey(actor.teamId, url);
const defaultCacheExpiry = 3600;
const unfurlResult = await CacheHelper.getDataOrSet<
Unfurl | { error: true }
>(
cacheKey,
async (): Promise<CacheResult<Unfurl | { error: true }> | undefined> => {
for (const plugin of plugins) {
const pluginName = plugin.name ?? "unknown";
const unfurl = await traceFunction({
spanName: "unfurl.plugin",
resourceName: pluginName,
tags: {
"unfurl.plugin": pluginName,
"unfurl.url_host": urlObj.hostname,
},
})(() => plugin.value.unfurl(url, actor))();
if (unfurl) {
if ("error" in unfurl) {
return { data: { error: true as const }, expiry: 60 };
}
return {
data: unfurl as Unfurl,
expiry: plugin.value.cacheExpiry,
};
}
}
return undefined;
},
defaultCacheExpiry
);
if (!unfurlResult || "error" in unfurlResult) {
ctx.response.status = 204;
return;
}
ctx.body = await presentUnfurl(unfurlResult);
return;
}
);
router.post(
"urls.checkEmbed",
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
auth(),
validate(T.UrlsCheckEmbedSchema),
async (ctx: APIContext<T.UrlsCheckEmbedReq>) => {
const { url } = ctx.input.body;
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
RedisPrefixHelper.getEmbedCheckKey(url),
() => checkEmbeddability(url),
Day.seconds
);
ctx.body = result
? { embeddable: result.embeddable, reason: result.reason }
: { embeddable: false, reason: "error" };
}
);
router.post(
"urls.validateCustomDomain",
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
auth(),
validate(T.UrlsCheckCnameSchema),
async (ctx: APIContext<T.UrlsCheckCnameReq>) => {
const { hostname } = ctx.input.body;
const [team, share] = await Promise.all([
Team.findByDomain(hostname),
Share.findOne({
where: {
domain: hostname,
},
}),
]);
if (team || share) {
throw ValidationError("Domain is already in use");
}
let addresses;
try {
addresses = await new Promise<string[]>((resolve, reject) => {
dns.resolveCname(hostname, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
});
});
} 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;