Skip to content

Commit ecdd5a3

Browse files
committed
Harden API security and fix integration bugs from senior code review
- Fix billing: unknown Stripe price IDs now default to free (was silently granting builder) - Fix MCP server: add 500-session cap with 30-min TTL to prevent memory exhaustion; add try/catch to GET/DELETE handlers - Fix billing portal: validate return_url against allowed hostnames (open redirect prevention) - Fix server: cache OpenAPI spec at startup instead of sync read per request - Fix Gemini integration: add encodeURIComponent to memory ID path; add missing browse filter - Fix n8n node: add encodeURIComponent to memory ID path; add missing browse filter; change credential test to /whoami - Fix stale plan enum in v1-migration-plan.md Made-with: Cursor
1 parent 8a2e44b commit ecdd5a3

7 files changed

Lines changed: 91 additions & 21 deletions

File tree

docs/v1-migration-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
- `clerk_id TEXT UNIQUE` -- Clerk user ID, populated when Clerk is integrated
2424
- `role TEXT NOT NULL DEFAULT 'user'` -- `'admin'`, `'private-alpha'`, or `'user'`
2525
- `stripe_customer_id TEXT UNIQUE` -- Stripe customer ID, populated on subscription
26-
- `plan TEXT NOT NULL DEFAULT 'free'` -- `'free'`, `'pro'`, or `'enterprise'`
26+
- `plan TEXT NOT NULL DEFAULT 'free'` -- `'free'` or `'builder'`
2727
- `updated_at TEXT NOT NULL` -- backfilled from `created_at` for existing rows
2828

2929
### Backward Compatibility

integrations/gemini/function-calling-example.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,15 @@ async function executeFunction(call: FunctionCall): Promise<unknown> {
168168
}
169169

170170
case "get_memory_by_id":
171-
return fetchApi(`/agent/memories/${args.id}`);
171+
return fetchApi(`/agent/memories/${encodeURIComponent(args.id as string)}`);
172172

173173
case "browse_memories":
174174
return fetchApi("/agent/memories/browse", {
175175
method: "POST",
176176
body: JSON.stringify({
177177
limit: args.limit ?? 50,
178178
offset: args.offset ?? 0,
179+
filter: { by: "all" },
179180
}),
180181
});
181182

integrations/n8n/n8n-nodes-reflect-memory/credentials/ReflectMemoryApi.credentials.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class ReflectMemoryApi implements ICredentialType {
4242
test: ICredentialTestRequest = {
4343
request: {
4444
baseURL: "={{$credentials.baseUrl}}",
45-
url: "/agent/memories/latest",
45+
url: "/whoami",
4646
method: "GET",
4747
},
4848
};

integrations/n8n/n8n-nodes-reflect-memory/nodes/ReflectMemory/ReflectMemory.node.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export class ReflectMemory implements INodeType {
228228
"reflectMemoryApi",
229229
{
230230
method: "GET",
231-
url: `${baseUrl}/agent/memories/${memoryId}`,
231+
url: `${baseUrl}/agent/memories/${encodeURIComponent(memoryId)}`,
232232
json: true,
233233
},
234234
) as IDataObject;
@@ -244,7 +244,7 @@ export class ReflectMemory implements INodeType {
244244
{
245245
method: "POST",
246246
url: `${baseUrl}/agent/memories/browse`,
247-
body: { limit, offset },
247+
body: { limit, offset, filter: { by: "all" } },
248248
json: true,
249249
},
250250
) as IDataObject;

src/billing-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export function handleStripeWebhook(
149149
function determinePlanFromPrice(priceId: string | undefined): string {
150150
if (!priceId) return "free";
151151
if (priceId === process.env.STRIPE_PRICE_BUILDER) return "builder";
152-
return "builder";
152+
return "free";
153153
}
154154

155155
export async function constructStripeEvent(

src/mcp-server.ts

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,20 +200,45 @@ export function startMcpServer(config: McpServerConfig, port: number): void {
200200
next();
201201
});
202202

203-
// Track transports by session ID, and which vendor owns each session
203+
const MAX_SESSIONS = 500;
204+
const SESSION_TTL_MS = 30 * 60 * 1000;
205+
204206
const transports: Record<string, StreamableHTTPServerTransport> = {};
205207
const sessionVendors: Record<string, string> = {};
208+
const sessionLastSeen: Record<string, number> = {};
209+
210+
setInterval(() => {
211+
const now = Date.now();
212+
for (const sid of Object.keys(sessionLastSeen)) {
213+
if (now - sessionLastSeen[sid] > SESSION_TTL_MS) {
214+
transports[sid]?.close?.();
215+
delete transports[sid];
216+
delete sessionVendors[sid];
217+
delete sessionLastSeen[sid];
218+
}
219+
}
220+
}, 60_000);
206221

207222
app.post("/mcp", async (req, res) => {
208223
try {
209224
const sessionId = req.headers["mcp-session-id"] as string | undefined;
210225

211226
if (sessionId && transports[sessionId]) {
227+
sessionLastSeen[sessionId] = Date.now();
212228
await transports[sessionId].handleRequest(req, res, req.body);
213229
return;
214230
}
215231

216232
if (!sessionId && isInitializeRequest(req.body)) {
233+
if (Object.keys(transports).length >= MAX_SESSIONS) {
234+
res.status(503).json({
235+
jsonrpc: "2.0",
236+
error: { code: -32000, message: "Too many active sessions" },
237+
id: null,
238+
});
239+
return;
240+
}
241+
217242
const vendor = (req as any).vendor as string;
218243

219244
const transport = new StreamableHTTPServerTransport({
@@ -222,6 +247,7 @@ export function startMcpServer(config: McpServerConfig, port: number): void {
222247
onsessioninitialized: (sid) => {
223248
transports[sid] = transport;
224249
sessionVendors[sid] = vendor;
250+
sessionLastSeen[sid] = Date.now();
225251
},
226252
});
227253

@@ -230,6 +256,7 @@ export function startMcpServer(config: McpServerConfig, port: number): void {
230256
if (sid) {
231257
delete transports[sid];
232258
delete sessionVendors[sid];
259+
delete sessionLastSeen[sid];
233260
}
234261
};
235262

@@ -257,21 +284,44 @@ export function startMcpServer(config: McpServerConfig, port: number): void {
257284
});
258285

259286
app.get("/mcp", async (req, res) => {
260-
const sessionId = req.headers["mcp-session-id"] as string | undefined;
261-
if (!sessionId || !transports[sessionId]) {
262-
res.status(400).send("Invalid or missing session ID");
263-
return;
287+
try {
288+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
289+
if (!sessionId || !transports[sessionId]) {
290+
res.status(400).send("Invalid or missing session ID");
291+
return;
292+
}
293+
sessionLastSeen[sessionId] = Date.now();
294+
await transports[sessionId].handleRequest(req, res);
295+
} catch (error) {
296+
console.error("[mcp] GET error:", error);
297+
if (!res.headersSent) {
298+
res.status(500).json({
299+
jsonrpc: "2.0",
300+
error: { code: -32603, message: "Internal server error" },
301+
id: null,
302+
});
303+
}
264304
}
265-
await transports[sessionId].handleRequest(req, res);
266305
});
267306

268307
app.delete("/mcp", async (req, res) => {
269-
const sessionId = req.headers["mcp-session-id"] as string | undefined;
270-
if (!sessionId || !transports[sessionId]) {
271-
res.status(400).send("Invalid or missing session ID");
272-
return;
308+
try {
309+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
310+
if (!sessionId || !transports[sessionId]) {
311+
res.status(400).send("Invalid or missing session ID");
312+
return;
313+
}
314+
await transports[sessionId].handleRequest(req, res);
315+
} catch (error) {
316+
console.error("[mcp] DELETE error:", error);
317+
if (!res.headersSent) {
318+
res.status(500).json({
319+
jsonrpc: "2.0",
320+
error: { code: -32603, message: "Internal server error" },
321+
id: null,
322+
});
323+
}
273324
}
274-
await transports[sessionId].handleRequest(req, res);
275325
});
276326

277327
app.get("/health", (_req, res) => {

src/server.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -635,13 +635,24 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
635635
"openapi-agent.yaml",
636636
);
637637

638+
let cachedOpenApiSpec: unknown = null;
639+
try {
640+
const yaml = require("js-yaml");
641+
cachedOpenApiSpec = yaml.load(readFileSync(openapiSpecPath, "utf-8"));
642+
} catch {
643+
console.warn("Could not pre-load OpenAPI spec; will attempt on first request");
644+
}
645+
638646
server.get("/openapi.json", async (_request, reply) => {
647+
if (cachedOpenApiSpec) {
648+
reply.type("application/json");
649+
return cachedOpenApiSpec;
650+
}
639651
try {
640652
const yaml = await import("js-yaml");
641-
const raw = readFileSync(openapiSpecPath, "utf-8");
642-
const spec = yaml.load(raw);
653+
cachedOpenApiSpec = yaml.load(readFileSync(openapiSpecPath, "utf-8"));
643654
reply.type("application/json");
644-
return spec;
655+
return cachedOpenApiSpec;
645656
} catch {
646657
reply.code(500);
647658
return { error: "Failed to load OpenAPI spec" };
@@ -1782,10 +1793,18 @@ export async function createServer(config: ServerConfig): Promise<FastifyInstanc
17821793
}
17831794

17841795
const body = (request.body ?? {}) as { return_url?: string };
1796+
let returnUrl = "https://www.reflectmemory.com/dashboard";
1797+
if (body.return_url) {
1798+
try {
1799+
const parsed = new URL(body.return_url);
1800+
const allowed = new Set(["www.reflectmemory.com", "reflectmemory.com", "localhost"]);
1801+
if (allowed.has(parsed.hostname)) returnUrl = body.return_url;
1802+
} catch { /* invalid URL, use default */ }
1803+
}
17851804
const url = await createBillingPortalSession(
17861805
db,
17871806
request.userId,
1788-
body.return_url ?? "https://www.reflectmemory.com/dashboard",
1807+
returnUrl,
17891808
);
17901809

17911810
if (!url) {

0 commit comments

Comments
 (0)