From 083bc7d0c669d1e0347303257316fea06081c661 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 08:35:40 -0400 Subject: [PATCH] bug: relay auth must be for the match in the url --- .../match-relay-auth-middleware.spec.ts | 123 ++++++++++++++++++ .../match-relay-auth-middleware.ts | 12 +- 2 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/matches/match-relay/match-relay-auth-middleware.spec.ts diff --git a/src/matches/match-relay/match-relay-auth-middleware.spec.ts b/src/matches/match-relay/match-relay-auth-middleware.spec.ts new file mode 100644 index 000000000..e14cdf05f --- /dev/null +++ b/src/matches/match-relay/match-relay-auth-middleware.spec.ts @@ -0,0 +1,123 @@ +import { Logger } from "@nestjs/common"; +import { MatchRelayAuthMiddleware } from "./match-relay-auth-middleware"; + +const MATCH_A = "11111111-1111-1111-1111-111111111111"; +const MATCH_B = "22222222-2222-2222-2222-222222222222"; + +function setup(passwords: Record) { + const hasura = { + query: jest.fn(async (query: any) => { + const id = query.matches_by_pk.__args.id; + return { + matches_by_pk: passwords[id] ? { password: passwords[id] } : null, + }; + }), + }; + const cache = { + remember: jest.fn(async (_key: string, fn: () => Promise) => fn()), + }; + const logger = { warn: jest.fn() } as unknown as Logger; + const middleware = new MatchRelayAuthMiddleware( + logger, + cache as any, + hasura as any, + ); + return { middleware, hasura }; +} + +function call( + middleware: MatchRelayAuthMiddleware, + url: string, + originAuth?: string, +) { + const end = jest.fn(); + const status = jest.fn(() => ({ end })); + const next = jest.fn(); + const request = { + method: "POST", + url, + headers: originAuth ? { "x-origin-auth": originAuth } : {}, + }; + return middleware + .use(request as any, { status } as any, next) + .then(() => ({ status, next })); +} + +describe("MatchRelayAuthMiddleware", () => { + it("lets a match's own server post to its relay", async () => { + const { middleware } = setup({ [MATCH_A]: "secret-a" }); + + const { status, next } = await call( + middleware, + `/match-relay/${MATCH_A}/s123t456/7/delta?final=0`, + `${MATCH_A}:secret-a`, + ); + + expect(next).toHaveBeenCalled(); + expect(status).not.toHaveBeenCalled(); + }); + + it("rejects one match's credential posting into another match's relay", async () => { + const { middleware, hasura } = setup({ + [MATCH_A]: "secret-a", + [MATCH_B]: "secret-b", + }); + + const { status, next } = await call( + middleware, + `/match-relay/${MATCH_B}/s999t999/0/start`, + `${MATCH_A}:secret-a`, + ); + + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(401); + expect(hasura.query).not.toHaveBeenCalled(); + }); + + it("rejects the wrong password for the path's match", async () => { + const { middleware } = setup({ [MATCH_B]: "secret-b" }); + + const { status, next } = await call( + middleware, + `/match-relay/${MATCH_B}/s1t2/0/full`, + `${MATCH_B}:not-the-password`, + ); + + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(401); + }); + + it("rejects a post with no x-origin-auth", async () => { + const { middleware } = setup({ [MATCH_A]: "secret-a" }); + + const { status, next } = await call( + middleware, + `/match-relay/${MATCH_A}/s1t2/0/full`, + ); + + expect(next).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(401); + }); + + it("binds the game streamer status route to its match the same way", async () => { + const { middleware } = setup({ + [MATCH_A]: "secret-a", + [MATCH_B]: "secret-b", + }); + + const own = await call( + middleware, + `/game-streamer/${MATCH_A}/status`, + `${MATCH_A}:secret-a`, + ); + const other = await call( + middleware, + `/game-streamer/${MATCH_B}/status`, + `${MATCH_A}:secret-a`, + ); + + expect(own.next).toHaveBeenCalled(); + expect(other.next).not.toHaveBeenCalled(); + expect(other.status).toHaveBeenCalledWith(401); + }); +}); diff --git a/src/matches/match-relay/match-relay-auth-middleware.ts b/src/matches/match-relay/match-relay-auth-middleware.ts index 0dd599c5a..2420671a9 100644 --- a/src/matches/match-relay/match-relay-auth-middleware.ts +++ b/src/matches/match-relay/match-relay-auth-middleware.ts @@ -33,7 +33,17 @@ export class MatchRelayAuthMiddleware implements NestMiddleware { const matchId = originAuth.substring(0, colonIndex); const apiPassword = originAuth.substring(colonIndex + 1); - const token = request.url.split("/")?.[3]; + const [, , pathMatchId, token] = request.url.split("?")[0].split("/"); + + // The header proves who is posting, the path says which match it lands + // on. Without tying them together any match's credential could write + // into (and a new token wipe) every other match's broadcast. + if (matchId !== pathMatchId) { + this.logger.warn( + `auth: rejecting ${request.method} ${request.url} — x-origin-auth is for match ${matchId}`, + ); + return response.status(401).end(); + } const matchPassword = await this.cache.remember( `match-relay-auth:${matchId}:${token}`,