Skip to content

Commit 06f99ae

Browse files
authored
fix: security release 2026-08-12 (#4735)
1 parent 518978b commit 06f99ae

30 files changed

Lines changed: 1238 additions & 72 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+
Reject alert webhook destinations in reserved benchmarking IP ranges.
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+
TRQL queries using the PREWHERE clause are now rejected with a clear error message. Use WHERE instead, which is filtered the same way but keeps your data isolation guarantees intact.

apps/webapp/app/models/orgIntegration.server.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ import { z } from "zod";
99
import { $transaction, prisma } from "~/db.server";
1010
import { env } from "~/env.server";
1111
import { logger } from "~/services/logger.server";
12+
import { redirectWithErrorMessage } from "./message.server";
1213
import { slackSecretLogFields } from "./safeIntegrationLog";
1314
import { slackAccessResultLogFields } from "./slackOAuthResultLog";
1415
import { getSecretStore } from "~/services/secrets/secretStore.server";
15-
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
16+
import {
17+
clearSlackOAuthSessionBinding,
18+
consumeSlackOAuthStateForSession,
19+
createSlackOAuthStateForSession,
20+
} from "~/models/slackOAuthState.server";
1621
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
1722

1823
const SlackSecretSchema = z.object({
@@ -27,8 +32,6 @@ const SlackSecretSchema = z.object({
2732

2833
type SlackSecret = z.infer<typeof SlackSecretSchema>;
2934

30-
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
31-
3235
export type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
3336
AuthenticatableIntegration,
3437
"service"
@@ -138,58 +141,60 @@ export class OrgIntegrationRepository {
138141

139142
static async redirectToAuthService(
140143
service: IntegrationService,
141-
state: string,
144+
organizationId: string,
145+
userId: string,
142146
request: Request,
143147
redirectTo: string
144148
) {
145-
const session = await getUserSession(request);
146-
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
147-
148-
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
149-
150-
if (!authUrl) {
149+
if (service !== "SLACK") {
151150
throw new Response("Unsupported service", { status: 400 });
152151
}
153152

153+
const { nonce, sessionCookie } = await createSlackOAuthStateForSession(request, {
154+
userId,
155+
organizationId,
156+
service: "slack",
157+
redirectTo,
158+
});
159+
160+
const authUrl = this.slackAuthorizationUrl(nonce);
161+
154162
logger.debug("Redirecting to auth service", {
155163
service,
156-
authUrl,
157164
redirectTo,
158165
});
159166

160167
return new Response(null, {
161168
status: 302,
162169
headers: {
163170
location: authUrl,
164-
"Set-Cookie": await commitSession(session),
171+
"Set-Cookie": sessionCookie,
165172
},
166173
});
167174
}
168175

169-
static async redirectAfterAuth(request: Request) {
170-
const session = await getUserSession(request);
171-
172-
logger.debug("Redirecting back after auth", {
173-
sessionData: session.data,
174-
});
175-
176-
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
176+
static async redirectAfterAuth(request: Request, redirectTo: string, errorMessage?: string) {
177+
const sessionCookie = await clearSlackOAuthSessionBinding(request);
177178

178-
if (!redirectTo) {
179-
throw new Response("Invalid redirect", { status: 400 });
179+
if (errorMessage) {
180+
const response = await redirectWithErrorMessage(redirectTo, request, errorMessage);
181+
response.headers.append("Set-Cookie", sessionCookie);
182+
return response;
180183
}
181184

182-
session.unset(REDIRECT_AFTER_AUTH_KEY);
183-
184185
return new Response(null, {
185186
status: 302,
186187
headers: {
187188
location: redirectTo,
188-
"Set-Cookie": await commitSession(session),
189+
"Set-Cookie": sessionCookie,
189190
},
190191
});
191192
}
192193

194+
static async consumeSlackOAuthState(request: Request, state: string, userId: string) {
195+
return consumeSlackOAuthStateForSession(request, state, userId);
196+
}
197+
193198
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
194199
switch (serviceName) {
195200
case "slack": {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { randomBytes } from "node:crypto";
2+
import { z } from "zod";
3+
import { env } from "~/env.server";
4+
import { createRedisClient, type RedisClient } from "~/redis.server";
5+
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
6+
import { singleton } from "~/utils/singleton";
7+
8+
const STATE_TTL_SECONDS = 10 * 60;
9+
const CREATE_ATTEMPTS = 2;
10+
const KEY_PREFIX = "oauth:slack:state:";
11+
const SLACK_OAUTH_SESSION_BINDING_KEY = "slack-oauth-session-binding";
12+
13+
const SlackOAuthStateSchema = z.object({
14+
userId: z.string(),
15+
sessionBinding: z.string(),
16+
organizationId: z.string(),
17+
service: z.literal("slack"),
18+
redirectTo: z.string().regex(/^\/(?!\/)/),
19+
});
20+
21+
export type SlackOAuthState = z.infer<typeof SlackOAuthStateSchema>;
22+
23+
type CreateSlackOAuthState = SlackOAuthState;
24+
type StartSlackOAuthState = Omit<CreateSlackOAuthState, "sessionBinding">;
25+
type ConsumeSlackOAuthState = Pick<SlackOAuthState, "userId" | "sessionBinding" | "service">;
26+
27+
const consumeScript = `
28+
local raw = redis.call("GET", KEYS[1])
29+
if not raw then return nil end
30+
local decoded, state = pcall(cjson.decode, raw)
31+
if not decoded or type(state) ~= "table" then return nil end
32+
if state.userId ~= ARGV[1] or state.sessionBinding ~= ARGV[2] or state.service ~= ARGV[3] then
33+
return nil
34+
end
35+
redis.call("DEL", KEYS[1])
36+
return raw
37+
`;
38+
39+
export class SlackOAuthStateStore {
40+
constructor(private readonly redis: Pick<RedisClient, "set" | "eval">) {}
41+
42+
async create(state: CreateSlackOAuthState): Promise<string> {
43+
const parsedState = SlackOAuthStateSchema.parse(state);
44+
45+
for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) {
46+
const nonce = randomBytes(32).toString("base64url");
47+
const created = await this.redis.set(
48+
this.#key(nonce),
49+
JSON.stringify(parsedState),
50+
"EX",
51+
STATE_TTL_SECONDS,
52+
"NX"
53+
);
54+
if (created === "OK") return nonce;
55+
}
56+
57+
throw new Error("Failed to create a unique Slack OAuth state");
58+
}
59+
60+
async consume(
61+
nonce: string,
62+
expected: ConsumeSlackOAuthState
63+
): Promise<SlackOAuthState | undefined> {
64+
if (!/^[A-Za-z0-9_-]{43}$/.test(nonce)) return undefined;
65+
66+
const raw = await this.redis.eval(
67+
consumeScript,
68+
1,
69+
this.#key(nonce),
70+
expected.userId,
71+
expected.sessionBinding,
72+
expected.service
73+
);
74+
if (typeof raw !== "string") return undefined;
75+
76+
try {
77+
return SlackOAuthStateSchema.safeParse(JSON.parse(raw)).data;
78+
} catch {
79+
return undefined;
80+
}
81+
}
82+
83+
#key(nonce: string): string {
84+
return `${KEY_PREFIX}{${nonce}}`;
85+
}
86+
}
87+
88+
export async function createSlackOAuthStateForSession(
89+
request: Request,
90+
state: StartSlackOAuthState,
91+
stateStore: SlackOAuthStateStore = getSlackOAuthStateStore()
92+
): Promise<{ nonce: string; sessionCookie: string }> {
93+
const session = await getUserSession(request);
94+
const sessionBinding = randomBytes(32).toString("base64url");
95+
const nonce = await stateStore.create({ ...state, sessionBinding });
96+
session.set(SLACK_OAUTH_SESSION_BINDING_KEY, sessionBinding);
97+
98+
return { nonce, sessionCookie: await commitSession(session) };
99+
}
100+
101+
export async function consumeSlackOAuthStateForSession(
102+
request: Request,
103+
nonce: string,
104+
userId: string,
105+
stateStore: SlackOAuthStateStore = getSlackOAuthStateStore()
106+
): Promise<SlackOAuthState | undefined> {
107+
const session = await getUserSession(request);
108+
const sessionBinding = session.get(SLACK_OAUTH_SESSION_BINDING_KEY);
109+
if (typeof sessionBinding !== "string") return undefined;
110+
111+
return stateStore.consume(nonce, { userId, sessionBinding, service: "slack" });
112+
}
113+
114+
export async function clearSlackOAuthSessionBinding(request: Request): Promise<string> {
115+
const session = await getUserSession(request);
116+
session.unset(SLACK_OAUTH_SESSION_BINDING_KEY);
117+
return commitSession(session);
118+
}
119+
120+
function getSlackOAuthStateStore(): SlackOAuthStateStore {
121+
if (!env.CACHE_REDIS_HOST) {
122+
throw new Error("Cache Redis is required for Slack OAuth state");
123+
}
124+
125+
return singleton(
126+
"slackOAuthStateStore",
127+
() =>
128+
new SlackOAuthStateStore(
129+
createRedisClient("trigger:slack-oauth-state", {
130+
host: env.CACHE_REDIS_HOST,
131+
port: env.CACHE_REDIS_PORT,
132+
username: env.CACHE_REDIS_USERNAME,
133+
password: env.CACHE_REDIS_PASSWORD,
134+
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
135+
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
136+
})
137+
)
138+
);
139+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { $replica } from "~/db.server";
2828
import { env } from "~/env.server";
2929
import { useOrganization } from "~/hooks/useOrganizations";
3030
import { inviteMembers } from "~/models/member.server";
31+
import { checkInviteRateLimit, InviteRateLimitError } from "~/services/inviteRateLimiter.server";
3132
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
3233
import { resolveOrgIdFromSlug } from "~/models/organization.server";
3334
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
@@ -176,6 +177,23 @@ export const action = dashboardAction(
176177
}
177178
}
178179

180+
// Every invite emails the address, so cap per-org and per-inviter sends
181+
// (same limiter as the invite-create API). With no org scope the
182+
// slug didn't resolve and inviteMembers rejects anyway.
183+
if (env.LOGIN_RATE_LIMITS_ENABLED && context.organizationId) {
184+
try {
185+
await checkInviteRateLimit(context.organizationId, userId, submission.value.emails.length);
186+
} catch (error) {
187+
if (error instanceof InviteRateLimitError) {
188+
return json(
189+
{ errors: { body: "Too many invites sent. Please try again later." } },
190+
{ status: 429 }
191+
);
192+
}
193+
throw error;
194+
}
195+
}
196+
179197
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
180198
// role → don't pass one through; the runtime fallback handles it.
181199
// Validation: the chosen role must be in the org's assignable set

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4747
return await OrgIntegrationRepository.redirectToAuthService(
4848
"SLACK",
4949
project.organizationId,
50+
userId,
5051
request,
5152
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
5253
slug: envParam,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4242
return await OrgIntegrationRepository.redirectToAuthService(
4343
"SLACK",
4444
project.organizationId,
45+
userId,
4546
request,
4647
v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam })
4748
);

apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { z } from "zod";
33
import { prisma } from "~/db.server";
44
import { env } from "~/env.server";
55
import { inviteMembers } from "~/models/member.server";
6+
import { checkInviteRateLimit, InviteRateLimitError } from "~/services/inviteRateLimiter.server";
67
import { logger } from "~/services/logger.server";
78
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
89
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
@@ -57,6 +58,26 @@ export const action = createActionPATApiRoute(
5758
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
5859
}
5960

61+
// Every invite emails the address, so cap per-org and per-inviter sends.
62+
if (env.LOGIN_RATE_LIMITS_ENABLED) {
63+
try {
64+
await checkInviteRateLimit(organization.id, authentication.userId, body.emails.length);
65+
} catch (error) {
66+
if (error instanceof InviteRateLimitError) {
67+
return json(
68+
{ error: "Too many invites sent. Please try again later." },
69+
{
70+
status: 429,
71+
headers: {
72+
"Retry-After": Math.ceil(error.retryAfter / 1000).toString(),
73+
},
74+
}
75+
);
76+
}
77+
throw error;
78+
}
79+
}
80+
6081
const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
6182
slug: organization.slug,
6283
emails: body.emails,

apps/webapp/app/routes/integrations.$serviceName.callback.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
22
import z from "zod";
3-
import { redirectBackWithErrorMessage } from "~/models/message.server";
43
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
54
import { requireUserId } from "~/services/session.server";
65
import { requestUrl } from "~/utils/requestUrl.server";
@@ -45,22 +44,35 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
4544

4645
const parsedParams = ParamsSchema.safeParse(params);
4746

48-
if (!parsedParams.success) {
47+
if (!parsedParams.success || parsedParams.data.serviceName !== "slack") {
4948
throw new Response("Invalid params", { status: 400 });
5049
}
5150

51+
const oauthState = await OrgIntegrationRepository.consumeSlackOAuthState(
52+
request,
53+
parsedSearchParams.data.state,
54+
userId
55+
);
56+
if (!oauthState) {
57+
throw new Response("Invalid state", { status: 400 });
58+
}
59+
5260
const service = new CreateOrgIntegrationService();
5361

5462
const integration = await service.call(
5563
userId,
56-
parsedSearchParams.data.state,
57-
parsedParams.data.serviceName,
64+
oauthState.organizationId,
65+
oauthState.service,
5866
parsedSearchParams.data.code
5967
);
6068

6169
if (integration) {
62-
return await OrgIntegrationRepository.redirectAfterAuth(request);
70+
return await OrgIntegrationRepository.redirectAfterAuth(request, oauthState.redirectTo);
6371
}
6472

65-
return redirectBackWithErrorMessage(request, "Failed to connect to the service");
73+
return await OrgIntegrationRepository.redirectAfterAuth(
74+
request,
75+
oauthState.redirectTo,
76+
"Failed to connect to the service"
77+
);
6678
}

0 commit comments

Comments
 (0)