From 83bf8478627bc1d56edadb049f61785b0305d453 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 14:22:27 -0700 Subject: [PATCH] Enforce account access restrictions across active sessions --- session-server/chat-manager.mjs | 21 ++++++ shared/sotce-ban.mjs | 18 ++++++ system/backend/authorization.mjs | 15 +++++ system/netlify/functions/sotce-net.mjs | 14 +++- system/tests/sotce-ban-authorization.test.mjs | 26 ++++++++ system/tests/sotce-ban.test.mjs | 64 +++++++++++++++++++ 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 shared/sotce-ban.mjs create mode 100644 system/tests/sotce-ban-authorization.test.mjs create mode 100644 system/tests/sotce-ban.test.mjs diff --git a/session-server/chat-manager.mjs b/session-server/chat-manager.mjs index 2f963e8939..07fbf52537 100644 --- a/session-server/chat-manager.mjs +++ b/session-server/chat-manager.mjs @@ -13,6 +13,7 @@ import { redact, unredact } from "./redact.mjs"; import { ensureIndexes as ensureHeartsIndexes, toggleHeart, countHearts } from "./hearts.mjs"; import { MongoClient, ObjectId } from "mongodb"; +import { sotceBanned } from "../shared/sotce-ban.mjs"; import { broadcastToTopic } from "../shared/push.mjs"; // Standard push (no Firebase). import { MAX_CHARS, @@ -382,6 +383,26 @@ export class ChatManager { msg.id = id; + // Check the authoritative identity on every Sotce mutation, including + // sockets authorized before a ban. Never trust the client-supplied sub. + if (instance.config.name === "chat-sotce" && + ["chat:message", "chat:delete", "chat:edit", "chat:heart"].includes(msg.type)) { + const token = msg.content?.token; + const cached = instance.authorizedConnections[id]; + const user = typeof token === "string" && token + ? (cached?.token === token ? cached.user : await this.authorize(instance, token)) + : undefined; + if (!user?.sub || (msg.content?.sub !== undefined && msg.content.sub !== user.sub) || + (await sotceBanned(this.db, user)) || (await this.accountLocked(user.sub))) { + delete instance.authorizedConnections[id]; + if (user?.sub) delete instance.subsToSubscribers[user.sub]; + ws.send(this.pack("unauthorized", { message: "Access unavailable." }, id)); + return; + } + msg.content.sub = user.sub; + instance.authorizedConnections[id] = { ...cached, token, user }; + } + if (msg.type === "logout") { console.log(`💬 [${instance.config.name}] User logged out`); delete instance.authorizedConnections[id]; diff --git a/shared/sotce-ban.mjs b/shared/sotce-ban.mjs new file mode 100644 index 0000000000..b58f85be92 --- /dev/null +++ b/shared/sotce-ban.mjs @@ -0,0 +1,18 @@ +// Durable Sotce bans. Auth0 blocks new logins; this also rejects existing tokens. +export async function sotceBanned(db, user) { + const keys = []; + if (typeof user?.sub === "string" && user.sub) keys.push(`sub:${user.sub}`); + if (typeof user?.email === "string" && user.email.trim()) { + keys.push(`email:${user.email.trim().toLowerCase()}`); + } + if (!keys.length || !db) return true; + try { + return !!(await db.collection("sotce-bans").findOne( + { _id: { $in: keys } }, + { projection: { _id: 1 }, maxTimeMS: 2000 }, + )); + } catch { + // Losing the ban store must not restore access to a banned account. + return true; + } +} diff --git a/system/backend/authorization.mjs b/system/backend/authorization.mjs index 071ad985ae..feffdc700e 100644 --- a/system/backend/authorization.mjs +++ b/system/backend/authorization.mjs @@ -8,6 +8,16 @@ import { connect } from "./database.mjs"; import * as KeyValue from "./kv.mjs"; import { shell } from "./shell.mjs"; import { accountLocked } from "./account-lock.mjs"; +import { sotceBanned } from "../../shared/sotce-ban.mjs"; + +async function sotceAccessDenied(user) { + try { + const { db } = await connect(); + return await sotceBanned(db, user); + } catch { + return true; + } +} const dev = process.env.CONTEXT === "dev"; const aestheticBaseURI = "https://aesthetic.us.auth0.com"; @@ -35,6 +45,10 @@ export async function authorize({ authorization }, tenant = "aesthetic") { userinfoCache.delete(cacheKey); return undefined; } + if (tenant === "sotce" && (await sotceAccessDenied(cached.user))) { + userinfoCache.delete(cacheKey); + return undefined; + } return { ...cached.user }; // Shallow copy so callers can't mutate the cache. } if (cached) userinfoCache.delete(cacheKey); @@ -55,6 +69,7 @@ export async function authorize({ authorization }, tenant = "aesthetic") { shell.log(`🔒 Refused locked account: ${result.sub}`); return undefined; } + if (tenant === "sotce" && (await sotceAccessDenied(result))) return undefined; if (result?.sub) { if (userinfoCache.size >= USERINFO_CACHE_MAX) { userinfoCache.delete(userinfoCache.keys().next().value); // Drop oldest. diff --git a/system/netlify/functions/sotce-net.mjs b/system/netlify/functions/sotce-net.mjs index 2587b6a66b..b4aa184ba6 100644 --- a/system/netlify/functions/sotce-net.mjs +++ b/system/netlify/functions/sotce-net.mjs @@ -98,6 +98,7 @@ import { broadcastToTopic, sendToUser } from "../../../shared/push.mjs"; import Stripe from "stripe"; import crypto from "node:crypto"; import { notificationChoiceUpdate } from "../../backend/notification-choice.mjs"; +import { sotceBanned } from "../../../shared/sotce-ban.mjs"; // The HTML shell is identical for every request to a given path (auth and all // dynamic content happen client-side), so render it once per title and serve @@ -10364,9 +10365,10 @@ export const handler = async (event, context) => { ? SOTCE_STRIPE_API_TEST_PUB_KEY : SOTCE_STRIPE_API_PUB_KEY}", ); + const token = window.sotceTOKEN || (await auth0Client.getTokenSilently()); const response = await fetch("/sotce-net/subscribe", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: "Bearer " + token }, body: JSON.stringify({ email: user.email, sub: user.sub }), }); if (response.ok) { @@ -10952,7 +10954,15 @@ export const handler = async (event, context) => { redirectPath, ); - const { email, sub } = JSON.parse(event.body); + const user = await authorize(event.headers, "sotce"); + if (!user?.email_verified) { + return respond(401, { message: "Please login with a verified email." }); + } + const { email, sub } = user; + const { db } = await connect(); + if (await sotceBanned(db, { email, sub })) { + return respond(403, { message: "Access unavailable." }); + } // Search for the customer by the metadata field 'sub' diff --git a/system/tests/sotce-ban-authorization.test.mjs b/system/tests/sotce-ban-authorization.test.mjs new file mode 100644 index 0000000000..01e9679231 --- /dev/null +++ b/system/tests/sotce-ban-authorization.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +let banned = false; +let authLookups = 0; +const user = { sub: "auth0|test", email: "reader@example.com", email_verified: true }; +mock.module("../backend/database.mjs", { namedExports: { + connect: async () => ({ db: { collection: () => ({ + findOne: async () => banned ? { _id: "ban" } : null, + }) } }), +} }); +mock.module("got", { namedExports: { + got: async () => { authLookups++; return { body: user }; }, +} }); +const { authorize } = await import("../backend/authorization.mjs"); + +test("a newly banned Sotce account loses cached and fresh authorization", async () => { + const headers = { authorization: "Bearer test-token" }; + assert.equal((await authorize(headers, "sotce"))?.sub, user.sub); + assert.equal(authLookups, 1); + banned = true; + assert.equal(await authorize(headers, "sotce"), undefined); + assert.equal(authLookups, 1, "cached token rejected without waiting for Auth0 cache expiry"); + assert.equal(await authorize({ authorization: "Bearer another-token" }, "sotce"), undefined); + assert.equal(authLookups, 2, "fresh token also rejected"); +}); diff --git a/system/tests/sotce-ban.test.mjs b/system/tests/sotce-ban.test.mjs new file mode 100644 index 0000000000..f43a955e6c --- /dev/null +++ b/system/tests/sotce-ban.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sotceBanned } from "../../shared/sotce-ban.mjs"; +import { ChatManager } from "../../session-server/chat-manager.mjs"; + +const user = { sub: "auth0|test", email: "reader@example.com" }; +function database(keys = []) { + return { collection(name) { + assert.equal(name, "sotce-bans"); + return { async findOne(query) { + return query._id.$in.some(key => keys.includes(key)) ? { _id: "ban" } : null; + } }; + } }; +} + +test("ban matches old tokens by sub and alternate accounts by normalized email", async () => { + assert.equal(await sotceBanned(database(["sub:" + user.sub]), { sub: user.sub }), true); + assert.equal(await sotceBanned(database(["email:reader@example.com"]), { + sub: "different-sub", email: " Reader@Example.COM ", + }), true); + assert.equal(await sotceBanned(database(), user), false); +}); + +test("missing identity and unavailable ban store fail closed", async () => { + assert.equal(await sotceBanned(database(), {}), true); + assert.equal(await sotceBanned(undefined, user), true); + assert.equal(await sotceBanned({ collection() { throw new Error("offline"); } }, user), true); +}); + +for (const type of ["chat:message", "chat:edit", "chat:delete", "chat:heart"]) { + test(`${type} rejects cached authorization after ban, before mutation`, async () => { + const manager = Object.create(ChatManager.prototype); + manager.db = database(["sub:" + user.sub]); + let mutations = 0; + for (const method of ["handleChatMessage", "handleEditMessage", "handleDeleteMessage", "handleChatHeart"]) + manager[method] = async () => mutations++; + const instance = { config: { name: "chat-sotce" }, + authorizedConnections: { socket: { token: "old-token", user } }, + subsToSubscribers: { [user.sub]: true } }; + const messages = []; + await manager.handleMessage(instance, { send: value => messages.push(value) }, "socket", + Buffer.from(JSON.stringify({ type, content: { token: "old-token", sub: user.sub } }))); + assert.equal(mutations, 0); + assert.equal(messages.length, 1); + assert.equal(instance.authorizedConnections.socket, undefined); + assert.equal(instance.subsToSubscribers[user.sub], undefined); + }); +} + +test("unbanned reader can mutate, but cannot claim another sub", async () => { + const manager = Object.create(ChatManager.prototype); + manager.db = database(); + manager.authorize = async () => user; + manager.accountLocked = async () => false; + let mutations = 0; + manager.handleChatMessage = async () => mutations++; + const instance = { config: { name: "chat-sotce" }, authorizedConnections: {}, subsToSubscribers: {} }; + const ws = { send() {} }; + const message = sub => Buffer.from(JSON.stringify({ type: "chat:message", content: { token: "token", sub } })); + await manager.handleMessage(instance, ws, "socket", message(user.sub)); + assert.equal(mutations, 1); + await manager.handleMessage(instance, ws, "socket", message("other-sub")); + assert.equal(mutations, 1); +}); -- 2.51.2