Skip to content

Commit 9dc737f

Browse files
d-cscarderne
authored andcommitted
fix(webapp): query & API endpoint hardening (query authz, prompt-override, ai-title rate limit) (#44)
1 parent 1067293 commit 9dc737f

11 files changed

Lines changed: 329 additions & 15 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Harden the query and prompt-override APIs and rate-limit the query ai-title endpoint

apps/webapp/app/routes/api.v1.prompts.$slug.override.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ const CreateBody = z.object({
1313
textContent: z.string(),
1414
model: z.string().optional(),
1515
commitMessage: z.string().optional(),
16-
source: z.string().optional(),
16+
// `code` is reserved for deploy-created versions.
17+
source: z
18+
.string()
19+
.refine((source) => source !== "code")
20+
.optional(),
1721
});
1822

1923
const UpdateBody = z.object({

apps/webapp/app/routes/api.v1.query.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createActionApiRoute, everyResource } from "~/services/routeBuilders/ap
55
import { executeQuery, type QueryScope } from "~/services/queryService.server";
66
import { logger } from "~/services/logger.server";
77
import { rowsToCSV } from "~/utils/dataExport";
8+
import { detectQueryTables } from "~/v3/detectQueryTables";
89
import { querySchemas } from "~/v3/querySchemas";
910

1011
const BodySchema = z.object({
@@ -16,14 +17,12 @@ const BodySchema = z.object({
1617
format: z.enum(["json", "csv"]).default("json"),
1718
});
1819

19-
/** Extract table names from a TRQL query for authorization */
20-
function detectTables(query: string): string[] {
21-
return querySchemas
22-
.filter((s) => {
23-
const escaped = s.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24-
return new RegExp(`\\bFROM\\s+${escaped}\\b`, "i").test(query);
25-
})
26-
.map((s) => s.name);
20+
const allowedQueryTables = new Set(querySchemas.map((s) => s.name));
21+
22+
/** Every table the query reads, for per-table JWT-scope authorization.
23+
* `null` means unparseable — callers deny by default. */
24+
function detectTables(query: string): string[] | null {
25+
return detectQueryTables(query, allowedQueryTables);
2726
}
2827

2928
const { action, loader } = createActionApiRoute(
@@ -34,12 +33,14 @@ const { action, loader } = createActionApiRoute(
3433
findResource: async () => 1,
3534
authorization: {
3635
action: "read",
37-
// A multi-table query reads from every detected table. Wrap with
38-
// everyResource so a JWT scoped to one table can't pass auth for
39-
// a query that also reads tables it isn't scoped to (would be the
40-
// same OR-loophole the batch trigger route had pre-fix).
36+
// A multi-table query reads from every detected table, so wrap with
37+
// everyResource: a JWT scoped to one table must not pass auth for a
38+
// query that also reads tables it isn't scoped to.
4139
resource: (_, __, ___, body) => {
4240
const tables = detectTables(body.query);
41+
// Unparseable query → deny. It must not fall through to the
42+
// permissive {type:"query",id:"all"} branch.
43+
if (tables === null) return { type: "query", id: "__unparseable__" };
4344
return tables.length > 0
4445
? everyResource(tables.map((id) => ({ type: "query", id })))
4546
: { type: "query", id: "all" };

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,34 @@ import { findProjectBySlug } from "~/models/project.server";
88
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
99
import { requireUserId } from "~/services/session.server";
1010
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
11+
import { aiTitleRateLimiter } from "~/v3/services/aiTitleRateLimiter.server";
1112
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
1213

14+
// `/resources/*` isn't covered by the global apiRateLimiter (`/api/*` only),
15+
// so this endpoint needs its own per-route limiter and length cap.
16+
const MAX_QUERY_LENGTH = 10_000;
17+
1318
const RequestSchema = z.object({
14-
query: z.string().min(1, "Query is required"),
19+
query: z.string().min(1, "Query is required").max(MAX_QUERY_LENGTH),
1520
queryId: z.string().optional(),
1621
});
1722

1823
export async function action({ request, params }: ActionFunctionArgs) {
1924
const userId = await requireUserId(request);
2025
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
2126

27+
const limit = await aiTitleRateLimiter.limit(`user:${userId}`);
28+
if (!limit.success) {
29+
return json(
30+
{
31+
success: false as const,
32+
error: "Too many requests — please wait a moment and try again.",
33+
title: null,
34+
},
35+
{ status: 429 }
36+
);
37+
}
38+
2239
// Parse the request body
2340
const [error, data] = await tryCatch(request.json());
2441
if (error) {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
parseTSQLSelect,
3+
SyntaxError as TSQLSyntaxError,
4+
type Field,
5+
type JoinExpr,
6+
type SelectQuery,
7+
type SelectSetQuery,
8+
} from "@internal/tsql";
9+
10+
/**
11+
* Extract every known table a TRQL query reads — the FROM table, every JOIN in
12+
* the chain, and any subqueries — for per-table JWT-scope authorization.
13+
*
14+
* `allowedTableNames` is the set of recognised table names (matched
15+
* case-insensitively); anything not in it is ignored. Injected so this stays
16+
* dependency-free (the caller derives it from the query schemas).
17+
*
18+
* Returns `null` when the query can't be parsed; callers MUST treat `null` as
19+
* deny-by-default.
20+
*/
21+
export function detectQueryTables(query: string, allowedTableNames: Set<string>): string[] | null {
22+
let ast: SelectQuery | SelectSetQuery;
23+
try {
24+
ast = parseTSQLSelect(query);
25+
} catch (err) {
26+
if (err instanceof TSQLSyntaxError) return null;
27+
throw err;
28+
}
29+
30+
const allowed = new Map(Array.from(allowedTableNames, (n) => [n.toLowerCase(), n]));
31+
const seen = new Set<string>();
32+
const scanned = new WeakSet<object>();
33+
34+
function visitSelect(q: SelectQuery): void {
35+
// CTE bodies: `WITH r AS (SELECT ... FROM <table>) ...` — the table is
36+
// read by the CTE even when the outer query only references the CTE alias.
37+
if (q.ctes) {
38+
for (const cte of Object.values(q.ctes)) {
39+
scanForSubqueries(cte.expr);
40+
}
41+
}
42+
// FROM / JOIN chain (tables + FROM-position subqueries).
43+
if (q.select_from) visitJoin(q.select_from);
44+
// Subqueries anywhere else (WHERE, SELECT list, GROUP BY, ORDER BY, etc.)
45+
// can each embed a SELECT that reads a real table, e.g.
46+
// `WHERE id IN (SELECT … FROM runs)`.
47+
scanForSubqueries(q.select);
48+
scanForSubqueries(q.where);
49+
scanForSubqueries(q.prewhere);
50+
scanForSubqueries(q.having);
51+
scanForSubqueries(q.group_by);
52+
scanForSubqueries(q.array_join_list);
53+
scanForSubqueries(q.order_by);
54+
scanForSubqueries(q.limit);
55+
scanForSubqueries(q.offset);
56+
scanForSubqueries(q.limit_by);
57+
scanForSubqueries(q.window_exprs);
58+
}
59+
// Shape-agnostic walk of an expression subtree: descends every nested
60+
// object/array and hands any embedded SELECT to the query visitors, so a new
61+
// node shape can't silently reintroduce a detection gap. The WeakSet guards
62+
// against back-reference cycles the AST might carry.
63+
function scanForSubqueries(node: unknown): void {
64+
if (node === null || typeof node !== "object") return;
65+
if (scanned.has(node)) return;
66+
scanned.add(node);
67+
if (Array.isArray(node)) {
68+
for (const item of node) scanForSubqueries(item);
69+
return;
70+
}
71+
const expressionType = (node as { expression_type?: string }).expression_type;
72+
if (expressionType === "select_query") {
73+
visitSelect(node as SelectQuery);
74+
return;
75+
}
76+
if (expressionType === "select_set_query") {
77+
visitSelectSet(node as SelectSetQuery);
78+
return;
79+
}
80+
for (const value of Object.values(node)) scanForSubqueries(value);
81+
}
82+
function visitSelectSet(q: SelectSetQuery): void {
83+
visitAny(q.initial_select_query);
84+
for (const node of q.subsequent_select_queries ?? []) {
85+
visitAny(node.select_query);
86+
}
87+
}
88+
function visitAny(q: SelectQuery | SelectSetQuery): void {
89+
if (q.expression_type === "select_query") visitSelect(q);
90+
else visitSelectSet(q);
91+
}
92+
function visitJoin(node: JoinExpr): void {
93+
const tableExpr = node.table;
94+
if (tableExpr) {
95+
if ((tableExpr as Field).expression_type === "field") {
96+
const name = (tableExpr as Field).chain[0];
97+
const canonicalName =
98+
typeof name === "string" ? allowed.get(name.toLowerCase()) : undefined;
99+
if (canonicalName) seen.add(canonicalName);
100+
} else if ((tableExpr as SelectQuery).expression_type === "select_query") {
101+
visitSelect(tableExpr as SelectQuery);
102+
} else if ((tableExpr as SelectSetQuery).expression_type === "select_set_query") {
103+
visitSelectSet(tableExpr as SelectSetQuery);
104+
}
105+
}
106+
if (node.next_join) visitJoin(node.next_join);
107+
}
108+
109+
if (ast.expression_type === "select_set_query") visitSelectSet(ast);
110+
else visitSelect(ast);
111+
112+
return Array.from(seen);
113+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Ratelimit } from "@upstash/ratelimit";
2+
import { type RedisWithClusterOptions } from "~/redis.server";
3+
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
4+
import { singleton } from "~/utils/singleton";
5+
6+
// The query ai-title endpoint lives under `/resources/*`, which the global
7+
// apiRateLimiter (only `/api/*`) does not cover, so it needs its own per-user
8+
// cap. Exported so the policy is asserted in tests rather than re-encoded.
9+
export const AI_TITLE_RATE_LIMIT_ATTEMPTS = 30;
10+
export const AI_TITLE_RATE_LIMIT_WINDOW = "10 m" as const;
11+
12+
/**
13+
* Build the ai-title per-user rate limiter. Production uses the env-derived
14+
* rate-limit Redis; tests inject a container Redis.
15+
*/
16+
export function createAITitleRateLimiter(redisOptions?: RedisWithClusterOptions): RateLimiter {
17+
return new RateLimiter({
18+
...(redisOptions ? { redisClient: createRedisRateLimitClient(redisOptions) } : {}),
19+
keyPrefix: "query.ai-title",
20+
limiter: Ratelimit.slidingWindow(AI_TITLE_RATE_LIMIT_ATTEMPTS, AI_TITLE_RATE_LIMIT_WINDOW),
21+
logFailure: true,
22+
});
23+
}
24+
25+
export const aiTitleRateLimiter = singleton("aiTitleRateLimiter", () => createAITitleRateLimiter());
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// `source: "code"` is reserved for the deploy path; no other caller may write
2+
// it. Normalize anything that isn't a legitimate caller-supplied value to
3+
// "dashboard". Dependency-free so the rule can be unit-tested directly; it
4+
// backs the service-layer check (the route layer also constrains `source`).
5+
export function normalizePromptOverrideSource(source: string | undefined | null): string {
6+
return source && source !== "code" ? source : "dashboard";
7+
}

apps/webapp/app/v3/services/promptService.server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "crypto";
22
import { prisma } from "~/db.server";
33
import { BaseService, ServiceValidationError } from "./baseService.server";
4+
import { normalizePromptOverrideSource } from "./promptOverrideSource";
45

56
export class PromptService extends BaseService {
67
async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) {
@@ -62,13 +63,17 @@ export class PromptService extends BaseService {
6263
WHERE "promptId" = ${promptId} AND 'override' = ANY("labels")
6364
`;
6465

66+
// Defence in depth: reject the reserved `code` source regardless of
67+
// caller, in case a future caller skips the route-layer check.
68+
const safeSource = normalizePromptOverrideSource(data.source);
69+
6570
await tx.promptVersion.create({
6671
data: {
6772
promptId,
6873
version: nextVersion,
6974
textContent: data.textContent,
7075
model: data.model || null,
71-
source: data.source || "dashboard",
76+
source: safeSource,
7277
commitMessage: data.commitMessage || null,
7378
contentHash,
7479
labels: ["override"],
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { redisTest } from "@internal/testcontainers";
2+
import { type RedisOptions } from "ioredis";
3+
import { describe, expect, vi } from "vitest";
4+
import { type RedisWithClusterOptions } from "../app/redis.server.js";
5+
import {
6+
AI_TITLE_RATE_LIMIT_ATTEMPTS,
7+
createAITitleRateLimiter,
8+
} from "../app/v3/services/aiTitleRateLimiter.server.js";
9+
10+
vi.setConfig({ testTimeout: 60_000 });
11+
12+
// Plaintext container: without tlsDisabled the client attempts TLS, the
13+
// connection fails, and @upstash/ratelimit fails open (allowing everything).
14+
const toRedisOptions = (o: RedisOptions): RedisWithClusterOptions => ({
15+
host: o.host,
16+
port: o.port,
17+
username: o.username,
18+
password: o.password,
19+
tlsDisabled: true,
20+
});
21+
22+
let seq = 0;
23+
const userKey = (label: string) => `user:${label}-${seq++}`;
24+
25+
// The query ai-title endpoint isn't covered by the global apiRateLimiter, so
26+
// this per-user limiter is the only thing bounding it.
27+
describe("aiTitleRateLimiter", () => {
28+
redisTest("allows up to the limit then blocks further attempts", async ({ redisOptions }) => {
29+
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
30+
const key = userKey("loop");
31+
32+
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
33+
const r = await limiter.limit(key);
34+
expect(r.success).toBe(true);
35+
}
36+
37+
const blocked = await limiter.limit(key);
38+
expect(blocked.success).toBe(false);
39+
});
40+
41+
redisTest("scopes the limit per user", async ({ redisOptions }) => {
42+
const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions));
43+
const victim = userKey("victim");
44+
const bystander = userKey("bystander");
45+
46+
for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) {
47+
await limiter.limit(victim);
48+
}
49+
expect((await limiter.limit(victim)).success).toBe(false);
50+
51+
// A different user is unaffected.
52+
expect((await limiter.limit(bystander)).success).toBe(true);
53+
});
54+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
import { detectQueryTables } from "../app/v3/detectQueryTables.js";
3+
4+
const allowed = new Set(["runs", "tasks"]);
5+
const sorted = (xs: string[] | null) => (xs === null ? null : [...xs].sort());
6+
7+
// detectQueryTables backs per-table JWT-scope authorization for /api/v1/query.
8+
// Key behaviours over the old FROM-only regex: it sees JOINed and subquery
9+
// tables, and returns null for unparseable queries so the caller denies by
10+
// default.
11+
describe("detectQueryTables", () => {
12+
it("detects the FROM table", () => {
13+
expect(sorted(detectQueryTables("SELECT * FROM runs", allowed))).toEqual(["runs"]);
14+
});
15+
16+
it("returns the canonical table name when query casing differs", () => {
17+
expect(sorted(detectQueryTables("SELECT * FROM RUNS", allowed))).toEqual(["runs"]);
18+
});
19+
20+
it("detects every JOINed table, not just FROM", () => {
21+
expect(
22+
sorted(detectQueryTables("SELECT * FROM runs JOIN tasks ON runs.id = tasks.run_id", allowed))
23+
).toEqual(["runs", "tasks"]);
24+
});
25+
26+
it("detects tables inside a FROM subquery", () => {
27+
expect(sorted(detectQueryTables("SELECT * FROM (SELECT * FROM runs) AS r", allowed))).toEqual([
28+
"runs",
29+
]);
30+
});
31+
32+
it("detects a table read only inside a CTE body", () => {
33+
expect(
34+
sorted(detectQueryTables("WITH r AS (SELECT * FROM runs) SELECT * FROM r", allowed))
35+
).toEqual(["runs"]);
36+
});
37+
38+
it("detects a table read only inside a WHERE subquery", () => {
39+
expect(
40+
sorted(
41+
detectQueryTables("SELECT * FROM tasks WHERE id IN (SELECT run_id FROM runs)", allowed)
42+
)
43+
).toEqual(["runs", "tasks"]);
44+
});
45+
46+
it("detects a table read only inside a SELECT-list subquery", () => {
47+
expect(
48+
sorted(detectQueryTables("SELECT (SELECT count() FROM runs) AS c FROM tasks", allowed))
49+
).toEqual(["runs", "tasks"]);
50+
});
51+
52+
it("ignores tables that aren't in the allowed schema set", () => {
53+
expect(detectQueryTables("SELECT * FROM runs", new Set(["tasks"]))).toEqual([]);
54+
});
55+
56+
it("returns null for an unparseable query (caller denies by default)", () => {
57+
expect(detectQueryTables("definitely not a valid query !!!", allowed)).toBeNull();
58+
});
59+
});

0 commit comments

Comments
 (0)