Implement signed cdn urls

This commit is contained in:
Puyodead1
2025-04-16 23:28:43 -04:00
committed by Rory&
parent 1d8e081fd8
commit 080b2c7d38
14 changed files with 279 additions and 13 deletions

136
src/util/Signing.ts Normal file
View 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();
};

View File

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

View File

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

View 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[];
}

View File

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

View 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[];
}

View File

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