Implement signed cdn urls
This commit is contained in:
parent
1d8e081fd8
commit
080b2c7d38
Binary file not shown.
Binary file not shown.
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
@ -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",
|
||||
|
||||
48
src/api/routes/attachments/refresh-urls.ts
Normal file
48
src/api/routes/attachments/refresh-urls.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -16,13 +16,18 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
136
src/util/Signing.ts
Normal file
136
src/util/Signing.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
};
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// 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"
|
||||
21
src/util/schemas/RefreshUrlsRequestSchema.ts
Normal file
21
src/util/schemas/RefreshUrlsRequestSchema.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface RefreshUrlsRequestSchema {
|
||||
attachment_urls: string[];
|
||||
}
|
||||
@ -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";
|
||||
|
||||
26
src/util/schemas/responses/RefreshUrlsResponse.ts
Normal file
26
src/util/schemas/responses/RefreshUrlsResponse.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface RefreshedUrl {
|
||||
original: string;
|
||||
refreshed: string;
|
||||
}
|
||||
|
||||
export interface RefreshUrlsResponse {
|
||||
refreshed_urls: RefreshedUrl[];
|
||||
}
|
||||
@ -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";
|
||||
|
||||
Reference in New Issue
Block a user