diff --git a/assets/openapi.json b/assets/openapi.json index c9fdd76c..c3124e1e 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index ef00cc31..44c77a77 100755 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/package-lock.json b/package-lock.json index 5717140d..a4dccca9 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 19324488..69779141 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "missing-native-js-functions": "^1.4.3", "module-alias": "^2.2.3", "morgan": "^1.10.0", + "ms": "^2.1.3", "multer": "^1.4.5-lts.1", "murmurhash-js": "^1.0.0", "node-2fa": "^2.0.3", diff --git a/src/api/routes/attachments/refresh-urls.ts b/src/api/routes/attachments/refresh-urls.ts new file mode 100644 index 00000000..e456a911 --- /dev/null +++ b/src/api/routes/attachments/refresh-urls.ts @@ -0,0 +1,48 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { route } from "@spacebar/api"; +import { RefreshUrlsRequestSchema, resignUrl } from "@spacebar/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.post( + "/", + route({ + requestBody: "RefreshUrlsRequestSchema", + responses: { + 200: { + body: "RefreshUrlsResponse", + }, + 400: { + body: "APIErrorResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { attachment_urls } = req.body as RefreshUrlsRequestSchema; + + const refreshed_urls = attachment_urls.map(resignUrl); + + return res.status(200).json({ + refreshed_urls, + }); + }, +); + +export default router; diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts index 4ba28927..a540dce6 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -35,6 +35,7 @@ import { emitEvent, getPermission, isTextChannel, + resignUrl, uploadFile, } from "@spacebar/util"; import { Request, Response, Router } from "express"; @@ -199,16 +200,18 @@ router.get( ? y.proxy_url : `https://example.org${y.proxy_url}`; - let pathname = new URL(uri).pathname; - while ( - pathname.split("/")[0] != "attachments" && - pathname.length > 30 - ) { - pathname = pathname.split("/").slice(1).join("/"); + const url = new URL(uri); + if (endpoint) { + const newBase = new URL(endpoint); + url.protocol = newBase.protocol; + url.hostname = newBase.hostname; + url.port = newBase.port; } - if (!endpoint?.endsWith("/")) pathname = "/" + pathname; - y.proxy_url = `${endpoint == null ? "" : endpoint}${pathname}`; + y.proxy_url = url.toString(); + + y.proxy_url = resignUrl(y.proxy_url); + y.url = resignUrl(y.url); }); /** diff --git a/src/cdn/routes/attachments.ts b/src/cdn/routes/attachments.ts index 19bb0b90..3b79e7f8 100644 --- a/src/cdn/routes/attachments.ts +++ b/src/cdn/routes/attachments.ts @@ -16,13 +16,18 @@ along with this program. If not, see . */ -import { Router, Response, Request } from "express"; -import { Config, Snowflake } from "@spacebar/util"; -import { storage } from "../util/Storage"; +import { + Config, + getUrlSignature, + hasValidSignature, + Snowflake, +} from "@spacebar/util"; +import { Request, Response, Router } from "express"; import FileType from "file-type"; +import imageSize from "image-size"; import { HTTPError } from "lambert-server"; import { multer } from "../util/multer"; -import imageSize from "image-size"; +import { storage } from "../util/Storage"; const router = Router(); @@ -39,6 +44,7 @@ router.post( async (req: Request, res: Response) => { if (req.headers.signature !== Config.get().security.requestSignature) throw new HTTPError("Invalid request signature"); + if (!req.file) throw new HTTPError("file missing"); const { buffer, mimetype, size, originalname } = req.file; @@ -63,12 +69,20 @@ router.post( } } + let finalUrl = `${endpoint}/${path}`; + + if (Config.get().security.cdnSignUrls) { + const signatureData = getUrlSignature(path); + console.log(signatureData); + finalUrl = `${finalUrl}?ex=${signatureData.expiresAt}&is=${signatureData.issuedAt}&hm=${signatureData.hash}&`; + } + const file = { id, content_type: mimetype, filename: filename, size, - url: `${endpoint}/${path}`, + url: finalUrl, width, height, }; @@ -84,6 +98,14 @@ router.get( // const { format } = req.query; const path = `attachments/${channel_id}/${id}/${filename}`; + + if ( + Config.get().security.cdnSignUrls && + !hasValidSignature(path, req.query) + ) { + return res.status(404).send("This content is no longer available."); + } + const file = await storage.get(path); if (!file) throw new HTTPError("File not found"); const type = await FileType.fromBuffer(file); diff --git a/src/util/Signing.ts b/src/util/Signing.ts new file mode 100644 index 00000000..5763ed16 --- /dev/null +++ b/src/util/Signing.ts @@ -0,0 +1,136 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import { Config } from "@spacebar/util"; +import { createHmac, timingSafeEqual } from "crypto"; +import ms, { StringValue } from "ms"; +import { ParsedQs } from "qs"; + +export const getUrlSignature = (path: string) => { + const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security; + + // calculate the expiration time + const now = Date.now(); + const issuedAt = now.toString(16); + const expiresAt = (now + ms(cdnSignatureDuration as StringValue)).toString( + 16, + ); + + // hash the url with the cdnSignatureKey + const hash = createHmac("sha256", cdnSignatureKey as string) + .update(path) + .update(issuedAt) + .update(expiresAt) + .digest("hex"); + + return { + hash, + issuedAt, + expiresAt, + }; +}; + +export const calculateHash = ( + url: string, + issuedAt: string, + expiresAt: string, +) => { + const { cdnSignatureKey } = Config.get().security; + const hash = createHmac("sha256", cdnSignatureKey as string) + .update(url) + .update(issuedAt) + .update(expiresAt) + .digest("hex"); + return hash; +}; + +export const isExpired = (ex: string, is: string) => { + // convert issued at + const issuedAt = parseInt(is, 16); + const expiresAt = parseInt(ex, 16); + + if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) { + // console.debug("Invalid timestamps in query"); + return true; + } + + const currentTime = Date.now(); + const isExpired = expiresAt < currentTime; + const isValidIssuedAt = issuedAt < currentTime; + if (isExpired || !isValidIssuedAt) { + // console.debug("Signature expired"); + return true; + } + + return false; +}; + +export const hasValidSignature = (path: string, query: ParsedQs) => { + // get url path + const { ex, is, hm } = query; + + // if the required query parameters are not present, return false + if (!ex || !is || !hm) return false; + + // check if the signature is expired + if (isExpired(ex as string, is as string)) { + return false; + } + + const calcd = calculateHash(path, is as string, ex as string); + const calculated = Buffer.from(calcd); + const received = Buffer.from(hm as string); + + const isHashValid = + calculated.length === received.length && + timingSafeEqual(calculated, received); + // if (!isHashValid) { + // console.debug("Invalid signature"); + // console.debug(calcd, hm); + // } + return isHashValid; +}; + +export const resignUrl = (attachmentUrl: string) => { + const url = new URL(attachmentUrl); + + // if theres an existing signature, check if its expired or not. no reason to resign if its not expired + if (url.searchParams.has("ex") && url.searchParams.has("is")) { + // extract the ex and is + const ex = url.searchParams.get("ex"); + const is = url.searchParams.get("is"); + + if (!isExpired(ex as string, is as string)) { + // if the signature is not expired, return the url as is + return attachmentUrl; + } + } + + let path = url.pathname; + // strip / from the start + if (path.startsWith("/")) { + path = path.slice(1); + } + + const { hash, issuedAt, expiresAt } = getUrlSignature(path); + url.searchParams.set("ex", expiresAt); + url.searchParams.set("is", issuedAt); + url.searchParams.set("hm", hash); + + return url.toString(); +}; diff --git a/src/util/config/types/SecurityConfiguration.ts b/src/util/config/types/SecurityConfiguration.ts index 38aab6f8..fb8b550e 100644 --- a/src/util/config/types/SecurityConfiguration.ts +++ b/src/util/config/types/SecurityConfiguration.ts @@ -37,4 +37,8 @@ export class SecurityConfiguration { mfaBackupCodeCount: number = 10; statsWorldReadable: boolean = true; defaultRegistrationTokenExpiration: number = 1000 * 60 * 60 * 24 * 7; //1 week + // cdn signed urls + cdnSignUrls: boolean = false; + cdnSignatureKey: string = crypto.randomBytes(32).toString("base64"); + cdnSignatureDuration: string = "24h"; } diff --git a/src/util/index.ts b/src/util/index.ts index c3d32bba..dba69812 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -16,6 +16,8 @@ along with this program. If not, see . */ +// NOTE: !! DO NOT REORDER THE IMPORTS !! + import "reflect-metadata"; export * from "./util/index"; @@ -26,3 +28,4 @@ export * from "./schemas"; export * from "./imports"; export * from "./config"; export * from "./connections"; +export * from "./Signing" \ No newline at end of file diff --git a/src/util/schemas/RefreshUrlsRequestSchema.ts b/src/util/schemas/RefreshUrlsRequestSchema.ts new file mode 100644 index 00000000..9c1df548 --- /dev/null +++ b/src/util/schemas/RefreshUrlsRequestSchema.ts @@ -0,0 +1,21 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export interface RefreshUrlsRequestSchema { + attachment_urls: string[]; +} diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index f2d5844b..9701faec 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -59,6 +59,7 @@ export * from "./MfaCodesSchema"; export * from "./ModifyGuildStickerSchema"; export * from "./PasswordResetSchema"; export * from "./PurgeSchema"; +export * from "./RefreshUrlsRequestSchema"; export * from "./RegisterSchema"; export * from "./RelationshipPostSchema"; export * from "./RelationshipPutSchema"; diff --git a/src/util/schemas/responses/RefreshUrlsResponse.ts b/src/util/schemas/responses/RefreshUrlsResponse.ts new file mode 100644 index 00000000..b08efaa4 --- /dev/null +++ b/src/util/schemas/responses/RefreshUrlsResponse.ts @@ -0,0 +1,26 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export interface RefreshedUrl { + original: string; + refreshed: string; +} + +export interface RefreshUrlsResponse { + refreshed_urls: RefreshedUrl[]; +} diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts index d65c7404..856b4a3e 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts @@ -44,6 +44,7 @@ export * from "./InstanceStatsResponse"; export * from "./LocationMetadataResponse"; export * from "./MemberJoinGuildResponse"; export * from "./OAuthAuthorizeResponse"; +export * from "./RefreshUrlsResponse"; export * from "./TeamListResponse"; export * from "./Tenor"; export * from "./TokenResponse";