summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-07-04 09:10:42 +0200
committerRory& <root@rory.gay>2025-07-06 18:03:17 +0200
commit2e196573ea1e257fc9266d6c63289c51fbaa4e12 (patch)
treeb92350a789b9e6ba29a4945725e126fdb1439590 /src/util
parentuse prototype when signing message (diff)
downloadserver-ts-2e196573ea1e257fc9266d6c63289c51fbaa4e12.tar.xz
Make IP/user-agent validation in CDN work
Diffstat (limited to 'src/util')
-rw-r--r--src/util/Signing.ts276
-rw-r--r--src/util/entities/Attachment.ts19
-rw-r--r--src/util/entities/Message.ts6
3 files changed, 203 insertions, 98 deletions
diff --git a/src/util/Signing.ts b/src/util/Signing.ts

index 2d3abe077..9d80297d2 100644 --- a/src/util/Signing.ts +++ b/src/util/Signing.ts
@@ -19,14 +19,105 @@ 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"; +import * as console from "node:console"; -export const getUrlSignature = ( - path: string, - req: Request, -) => { +export class NewUrlUserSignatureData { + ip?: string; + userAgent?: string; + constructor(data: NewUrlUserSignatureData) { + this.ip = data.ip; + this.userAgent = data.userAgent; + } +} + +export class NewUrlSignatureData extends NewUrlUserSignatureData { + path?: string; + url?: string; + + constructor(data: NewUrlSignatureData) { + super(data); + this.path = data.path; + this.url = data.url; + if (!this.path && !this.url) { + throw new Error("Either path or url must be provided for URL signing"); + } + if (this.path && this.url) { + console.warn( + "[Signing] Both path and url are provided, using path for signing", + ); + } + if (this.url) { + try { + const parsedUrl = new URL(this.url); + this.path = parsedUrl.pathname; + } catch (e) { + throw new Error("Invalid URL provided for signing: " + this.url); + } + } + } +} + +export class UrlSignatureData extends NewUrlSignatureData { + issuedAt: string; + expiresAt: string; + + constructor(data: UrlSignatureData) { + super(data); + this.issuedAt = data.issuedAt; + this.expiresAt = data.expiresAt; + } +} + +export class UrlSignResult { + path: string; + hash: string; + issuedAt: string; + expiresAt: string; + + /* + * @param data {UrlSignResult} + */ + constructor(data: any) { + for (const key in data) { + // @ts-ignore TS7053 - We dont care about string indexing a class + this[key] = data[key]; + } + } + + applyToUrl(url: URL | string): URL { + if (typeof url === "string") { + url = new URL(url); + } + url.searchParams.set("ex", this.expiresAt); + url.searchParams.set("is", this.issuedAt); + url.searchParams.set("hm", this.hash); + return url; + } + + static fromUrl(url: URL | string): UrlSignResult { + if (typeof url === "string") { + console.debug("[Signing] Parsing URL from string:", url); + url = new URL(url); + } + console.debug("[Signing] Parsing URL from URL object:", url.toString()); + const ex = url.searchParams.get("ex"); + const is = url.searchParams.get("is"); + const hm = url.searchParams.get("hm"); + + if (!ex || !is || !hm) { + throw new Error("Invalid URL signature parameters"); + } + + return new UrlSignResult({ + path: url.pathname, + issuedAt: is, + expiresAt: ex, + hash: hm, + }); + } +} + +export const getUrlSignature = (data: NewUrlSignatureData): UrlSignResult => { const { cdnSignatureKey, cdnSignatureDuration } = Config.get().security; // calculate the expiration time @@ -37,135 +128,142 @@ export const getUrlSignature = ( ); // hash the url with the cdnSignatureKey - const hash = calculateHash(path, issuedAt, expiresAt, req); - - return { - hash, - issuedAt, - expiresAt, - }; + return calculateHash( + new UrlSignatureData({ + ...data, + issuedAt, + expiresAt, + }), + ); }; -export const calculateHash = ( - url: string, - issuedAt: string, - expiresAt: string, - req: Request, -) => { +function calculateHash(request: UrlSignatureData): UrlSignResult { const { cdnSignatureKey } = Config.get().security; const data = createHmac("sha256", cdnSignatureKey as string) - .update(url) - .update(issuedAt) - .update(expiresAt); + .update(request.path!) + .update(request.issuedAt) + .update(request.expiresAt); if (Config.get().security.cdnSignatureIncludeIp) { - if (!req || !req.ip) + if (!request.ip) + console.log( + "[Signing] CDN Signature IP is enabled but we couldn't find the IP field in the request. This may cause issues with signature validation. Please report this to the Spacebar team!", + ); + else { 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!", + "[Signing] CDN Signature IP is enabled, adding IP to hash:", + request.ip, ); - console.log("[Signing] CDN Signature IP is enabled, adding IP to hash:", req.ip); - data.update(req.ip!); + data.update(request.ip!); + } } if (Config.get().security.cdnSignatureIncludeUserAgent) { - if (!req || !req.headers || !req.headers["user-agent"]) + if (!request.userAgent) + console.log( + "[Signing] CDN Signature User-Agent is enabled but we couldn't find the user-agent header in the request. This may cause issues with signature validation. Please report this to the Spacebar team!", + ); + else { 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!", + "[Signing] CDN Signature User-Agent is enabled, adding User-Agent to hash:", + request.userAgent, ); - data.update(req.headers["user-agent"] as string); + data.update(request.userAgent!); + } } - const hash = data.digest("hex"); + const result = new UrlSignResult({ + path: request.path, + issuedAt: request.issuedAt, + expiresAt: request.expiresAt, + hash, + }); 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; -}; + path: request.path, + validity: request.issuedAt + " .. " + request.expiresAt, + ua: Config.get().security.cdnSignatureIncludeUserAgent ? request.userAgent : "[disabled]", + ip: Config.get().security.cdnSignatureIncludeIp ? request.ip : "[disabled]" + }, "->", result); + return result; +} -export const isExpired = (ex: string, is: string) => { +export const isExpired = (data: UrlSignResult | UrlSignatureData) => { // convert issued at - const issuedAt = parseInt(is, 16); - const expiresAt = parseInt(ex, 16); + const issuedAt = parseInt(data.issuedAt, 16); + const expiresAt = parseInt(data.expiresAt, 16); if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) { - // console.debug("Invalid timestamps in query"); + console.debug("[Signing] Invalid timestamps in query"); return true; } const currentTime = Date.now(); + const isExpired = expiresAt < currentTime; + if (isExpired) { + console.debug("[Signing] Signature expired"); + return true; + } + const isValidIssuedAt = issuedAt < currentTime; - if (isExpired || !isValidIssuedAt) { - // console.debug("Signature expired"); + if (!isValidIssuedAt) { + console.debug("[Signing] Signature issued in the future"); return true; } return false; }; -export const hasValidSignature = (path: string, query: ParsedQs, req: Request) => { - // get url path - const { ex, is, hm } = query; - +export const hasValidSignature = (req: NewUrlUserSignatureData, sig: UrlSignResult) => { // if the required query parameters are not present, return false - if (!ex || !is || !hm) { - console.debug("Missing required query parameters for signature validation"); + if (!sig.expiresAt || !sig.issuedAt || !sig.hash) { + console.warn( + "[Signing] 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"); + if (isExpired(sig)) { + console.warn("[Signing] Signature is expired"); return false; } - const calcd = calculateHash(path, is as string, ex as string, req); + const calcResult = calculateHash(new UrlSignatureData({ + path: sig.path, + issuedAt: sig.issuedAt, + expiresAt: sig.expiresAt, + ip: req.ip, + userAgent: req.userAgent + })); + const calcd = calcResult.hash; const calculated = Buffer.from(calcd); - const received = Buffer.from(hm as string); + const received = Buffer.from(sig.hash as string); + + console.assert(sig.issuedAt == calcResult.issuedAt, "[Signing] Mismatched issuedAt", { + is: sig.issuedAt, + calculated: calcResult.issuedAt, + }); + + console.assert(sig.expiresAt == calcResult.expiresAt, "[Signing] Mismatched expiresAt", { + ex: sig.expiresAt, + calculated: calcResult.expiresAt, + }); + + console.assert(calculated.length === received.length, "[Signing] Mismatched hash length", { + calculated: calculated.length, + received: received.length, + }); const isHashValid = calculated.length === received.length && timingSafeEqual(calculated, received); - console.debug( - `Signature validation for ${path} - isHashValid: ${isHashValid}, calculated: ${calcd}, received: ${hm}`, - ); + if (!isHashValid) + console.warn( + `Signature validation for ${sig.path} (is=${sig.issuedAt}, ex=${sig.expiresAt}) failed: calculated: ${calcd}, received: ${sig.hash}`, + ); 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(); -}; diff --git a/src/util/entities/Attachment.ts b/src/util/entities/Attachment.ts
index 1141d08fe..c29ba310e 100644 --- a/src/util/entities/Attachment.ts +++ b/src/util/entities/Attachment.ts
@@ -28,8 +28,11 @@ import { URL } from "url"; import { deleteFile } from "../util/cdn"; import { BaseClass } from "./BaseClass"; import { dbEngine } from "../util/Database"; -import { Request } from "express"; -import { resignUrl } from "../Signing"; +import { + getUrlSignature, + NewUrlUserSignatureData, + NewUrlSignatureData, +} from "../Signing"; @Entity({ name: "attachments", @@ -76,11 +79,15 @@ export class Attachment extends BaseClass { return deleteFile(new URL(this.url).pathname); } - signUrls(req: Request) { + signUrls(data: NewUrlUserSignatureData): Attachment { return { ...this, - url: resignUrl(this.url, req), - proxy_url: resignUrl(this.proxy_url, req), - } + url: getUrlSignature( + new NewUrlSignatureData({ ...data, url: this.url }), + ).applyToUrl(this.url).toString(), + proxy_url: getUrlSignature( + new NewUrlSignatureData({ ...data, url: this.proxy_url }), + ).applyToUrl(this.proxy_url).toString(), + }; } } diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index cc96843f2..52c82cd5b 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts
@@ -40,7 +40,7 @@ import { Webhook } from "./Webhook"; import { Sticker } from "./Sticker"; import { Attachment } from "./Attachment"; import { dbEngine } from "../util/Database"; -import { Request } from "express"; +import { NewUrlUserSignatureData } from "../Signing"; export enum MessageType { DEFAULT = 0, @@ -262,11 +262,11 @@ export class Message extends BaseClass { }; } - withSignedAttachments(req: Request) { + withSignedAttachments(data: NewUrlUserSignatureData) { return { ...this, attachments: this.attachments?.map((attachment: Attachment) => - Attachment.prototype.signUrls.call(attachment, req) + Attachment.prototype.signUrls.call(attachment, data) ) }; }