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";