This repository has been archived on 2026-02-28. You can view files and clone it, but cannot push or open issues or pull requests.
Emma [it/its]@Rory& 387cef269a Sign media per user
2025-07-06 18:03:17 +02:00

172 lines
5.0 KiB
TypeScript

/*
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";
import { Request } from "express";
import attachments from "../cdn/routes/attachments";
export const getUrlSignature = (
path: string,
req: Request,
) => {
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 = calculateHash(path, issuedAt, expiresAt, req);
return {
hash,
issuedAt,
expiresAt,
};
};
export const calculateHash = (
url: string,
issuedAt: string,
expiresAt: string,
req: Request,
) => {
const { cdnSignatureKey } = Config.get().security;
const data = createHmac("sha256", cdnSignatureKey as string)
.update(url)
.update(issuedAt)
.update(expiresAt);
if (Config.get().security.cdnSignatureIncludeIp) {
if (!req || !req.ip)
console.log(
"[Signing] CDN Signature IP is enabled but no request object was provided. This may cause issues with signature validation. Please report this to the Spacebar team!",
);
console.log("[Signing] CDN Signature IP is enabled, adding IP to hash:", req.ip);
data.update(req.ip!);
}
if (Config.get().security.cdnSignatureIncludeUserAgent) {
if (!req || !req.headers || !req.headers["user-agent"])
console.log(
"[Signing] CDN Signature User-Agent is enabled but no request object was provided. This may cause issues with signature validation. Please report this to the Spacebar team!",
);
data.update(req.headers["user-agent"] as string);
}
const hash = data.digest("hex");
console.log("[Signing]", {
url,
issuedAt,
expiresAt,
includeUA: Config.get().security.cdnSignatureIncludeUserAgent,
ua: req.headers["user-agent"],
includeIP: Config.get().security.cdnSignatureIncludeIp,
ip: req.ip,
}, "->", hash);
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, req: Request) => {
// get url path
const { ex, is, hm } = query;
// if the required query parameters are not present, return false
if (!ex || !is || !hm) {
console.debug("Missing required query parameters for signature validation");
return false;
}
// check if the signature is expired
if (isExpired(ex as string, is as string)) {
console.debug("Signature is expired");
return false;
}
const calcd = calculateHash(path, is as string, ex as string, req);
const calculated = Buffer.from(calcd);
const received = Buffer.from(hm as string);
const isHashValid =
calculated.length === received.length &&
timingSafeEqual(calculated, received);
console.debug(
`Signature validation for ${path} - isHashValid: ${isHashValid}, calculated: ${calcd}, received: ${hm}`,
);
return isHashValid;
};
export const resignUrl = (attachmentUrl: string, req: Request) => {
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, req);
url.searchParams.set("ex", expiresAt);
url.searchParams.set("is", issuedAt);
url.searchParams.set("hm", hash);
return url.toString();
};