Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/reject-benchmarking-webhook-addresses.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Reject alert webhook destinations in reserved benchmarking IP ranges.
Comment thread
carderne marked this conversation as resolved.
6 changes: 6 additions & 0 deletions .server-changes/reject-prewhere-in-trql.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

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.
55 changes: 30 additions & 25 deletions apps/webapp/app/models/orgIntegration.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import { z } from "zod";
import { $transaction, prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { redirectWithErrorMessage } from "./message.server";
import { slackSecretLogFields } from "./safeIntegrationLog";
import { slackAccessResultLogFields } from "./slackOAuthResultLog";
import { getSecretStore } from "~/services/secrets/secretStore.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import {
clearSlackOAuthSessionBinding,
consumeSlackOAuthStateForSession,
createSlackOAuthStateForSession,
} from "~/models/slackOAuthState.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";

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

type SlackSecret = z.infer<typeof SlackSecretSchema>;

const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";

export type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
AuthenticatableIntegration,
"service"
Expand Down Expand Up @@ -138,58 +141,60 @@ export class OrgIntegrationRepository {

static async redirectToAuthService(
service: IntegrationService,
state: string,
organizationId: string,
userId: string,
request: Request,
redirectTo: string
) {
const session = await getUserSession(request);
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);

const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;

if (!authUrl) {
if (service !== "SLACK") {
throw new Response("Unsupported service", { status: 400 });
}

const { nonce, sessionCookie } = await createSlackOAuthStateForSession(request, {
userId,
organizationId,
service: "slack",
redirectTo,
});

const authUrl = this.slackAuthorizationUrl(nonce);

logger.debug("Redirecting to auth service", {
service,
authUrl,
redirectTo,
});

return new Response(null, {
status: 302,
headers: {
location: authUrl,
"Set-Cookie": await commitSession(session),
"Set-Cookie": sessionCookie,
},
});
}

static async redirectAfterAuth(request: Request) {
const session = await getUserSession(request);

logger.debug("Redirecting back after auth", {
sessionData: session.data,
});

const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
static async redirectAfterAuth(request: Request, redirectTo: string, errorMessage?: string) {
const sessionCookie = await clearSlackOAuthSessionBinding(request);

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

session.unset(REDIRECT_AFTER_AUTH_KEY);

return new Response(null, {
status: 302,
headers: {
location: redirectTo,
"Set-Cookie": await commitSession(session),
"Set-Cookie": sessionCookie,
},
});
}

static async consumeSlackOAuthState(request: Request, state: string, userId: string) {
return consumeSlackOAuthStateForSession(request, state, userId);
}

static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
switch (serviceName) {
case "slack": {
Expand Down
139 changes: 139 additions & 0 deletions apps/webapp/app/models/slackOAuthState.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { randomBytes } from "node:crypto";
import { z } from "zod";
import { env } from "~/env.server";
import { createRedisClient, type RedisClient } from "~/redis.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import { singleton } from "~/utils/singleton";

const STATE_TTL_SECONDS = 10 * 60;
const CREATE_ATTEMPTS = 2;
const KEY_PREFIX = "oauth:slack:state:";
const SLACK_OAUTH_SESSION_BINDING_KEY = "slack-oauth-session-binding";

const SlackOAuthStateSchema = z.object({
userId: z.string(),
sessionBinding: z.string(),
organizationId: z.string(),
service: z.literal("slack"),
redirectTo: z.string().regex(/^\/(?!\/)/),
});
Comment thread
carderne marked this conversation as resolved.

export type SlackOAuthState = z.infer<typeof SlackOAuthStateSchema>;

type CreateSlackOAuthState = SlackOAuthState;
type StartSlackOAuthState = Omit<CreateSlackOAuthState, "sessionBinding">;
type ConsumeSlackOAuthState = Pick<SlackOAuthState, "userId" | "sessionBinding" | "service">;

const consumeScript = `
local raw = redis.call("GET", KEYS[1])
if not raw then return nil end
local decoded, state = pcall(cjson.decode, raw)
if not decoded or type(state) ~= "table" then return nil end
if state.userId ~= ARGV[1] or state.sessionBinding ~= ARGV[2] or state.service ~= ARGV[3] then
return nil
end
redis.call("DEL", KEYS[1])
return raw
`;

export class SlackOAuthStateStore {
constructor(private readonly redis: Pick<RedisClient, "set" | "eval">) {}

async create(state: CreateSlackOAuthState): Promise<string> {
const parsedState = SlackOAuthStateSchema.parse(state);

for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) {
const nonce = randomBytes(32).toString("base64url");
const created = await this.redis.set(
this.#key(nonce),
JSON.stringify(parsedState),
"EX",
STATE_TTL_SECONDS,
"NX"
);
if (created === "OK") return nonce;
}

throw new Error("Failed to create a unique Slack OAuth state");
}

async consume(
nonce: string,
expected: ConsumeSlackOAuthState
): Promise<SlackOAuthState | undefined> {
if (!/^[A-Za-z0-9_-]{43}$/.test(nonce)) return undefined;

const raw = await this.redis.eval(
consumeScript,
1,
this.#key(nonce),
expected.userId,
expected.sessionBinding,
expected.service
);
if (typeof raw !== "string") return undefined;

try {
return SlackOAuthStateSchema.safeParse(JSON.parse(raw)).data;
} catch {
return undefined;
}
}

#key(nonce: string): string {
return `${KEY_PREFIX}{${nonce}}`;
}
}

export async function createSlackOAuthStateForSession(
request: Request,
state: StartSlackOAuthState,
stateStore: SlackOAuthStateStore = getSlackOAuthStateStore()
): Promise<{ nonce: string; sessionCookie: string }> {
const session = await getUserSession(request);
const sessionBinding = randomBytes(32).toString("base64url");
const nonce = await stateStore.create({ ...state, sessionBinding });
session.set(SLACK_OAUTH_SESSION_BINDING_KEY, sessionBinding);

return { nonce, sessionCookie: await commitSession(session) };
}

export async function consumeSlackOAuthStateForSession(
request: Request,
nonce: string,
userId: string,
stateStore: SlackOAuthStateStore = getSlackOAuthStateStore()
): Promise<SlackOAuthState | undefined> {
const session = await getUserSession(request);
const sessionBinding = session.get(SLACK_OAUTH_SESSION_BINDING_KEY);
if (typeof sessionBinding !== "string") return undefined;

return stateStore.consume(nonce, { userId, sessionBinding, service: "slack" });
}
Comment thread
carderne marked this conversation as resolved.

export async function clearSlackOAuthSessionBinding(request: Request): Promise<string> {
const session = await getUserSession(request);
session.unset(SLACK_OAUTH_SESSION_BINDING_KEY);
return commitSession(session);
}

function getSlackOAuthStateStore(): SlackOAuthStateStore {
if (!env.CACHE_REDIS_HOST) {
throw new Error("Cache Redis is required for Slack OAuth state");
}

return singleton(
"slackOAuthStateStore",
() =>
new SlackOAuthStateStore(
createRedisClient("trigger:slack-oauth-state", {
host: env.CACHE_REDIS_HOST,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
})
)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { useOrganization } from "~/hooks/useOrganizations";
import { inviteMembers } from "~/models/member.server";
import { checkInviteRateLimit, InviteRateLimitError } from "~/services/inviteRateLimiter.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
Expand Down Expand Up @@ -176,6 +177,23 @@ export const action = dashboardAction(
}
}

// Every invite emails the address, so cap per-org and per-inviter sends
// (same limiter as the invite-create API). With no org scope the
// slug didn't resolve and inviteMembers rejects anyway.
if (env.LOGIN_RATE_LIMITS_ENABLED && context.organizationId) {
try {
await checkInviteRateLimit(context.organizationId, userId, submission.value.emails.length);
} catch (error) {
if (error instanceof InviteRateLimitError) {
return json(
{ errors: { body: "Too many invites sent. Please try again later." } },
{ status: 429 }
);
}
throw error;
}
}

// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
// role → don't pass one through; the runtime fallback handles it.
// Validation: the chosen role must be in the org's assignable set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
userId,
request,
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
slug: envParam,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
return await OrgIntegrationRepository.redirectToAuthService(
"SLACK",
project.organizationId,
userId,
request,
v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam })
);
Expand Down
21 changes: 21 additions & 0 deletions apps/webapp/app/routes/api.v1.orgs.$orgParam.invites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { inviteMembers } from "~/models/member.server";
import { checkInviteRateLimit, InviteRateLimitError } from "~/services/inviteRateLimiter.server";
import { logger } from "~/services/logger.server";
import { resolveOrganizationForApiUser } from "~/services/organizationApiAccess.server";
import { createActionPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
Expand Down Expand Up @@ -57,6 +58,26 @@ export const action = createActionPATApiRoute(
return json({ error: "Membership is managed by Directory Sync" }, { status: 403 });
}

// Every invite emails the address, so cap per-org and per-inviter sends.
if (env.LOGIN_RATE_LIMITS_ENABLED) {
try {
await checkInviteRateLimit(organization.id, authentication.userId, body.emails.length);
} catch (error) {
if (error instanceof InviteRateLimitError) {
return json(
{ error: "Too many invites sent. Please try again later." },
{
status: 429,
headers: {
"Retry-After": Math.ceil(error.retryAfter / 1000).toString(),
},
}
);
}
throw error;
}
}

const { created, alreadyMembers, alreadyInvited } = await inviteMembers({
slug: organization.slug,
emails: body.emails,
Expand Down
24 changes: 18 additions & 6 deletions apps/webapp/app/routes/integrations.$serviceName.callback.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import z from "zod";
import { redirectBackWithErrorMessage } from "~/models/message.server";
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
import { requireUserId } from "~/services/session.server";
import { requestUrl } from "~/utils/requestUrl.server";
Expand Down Expand Up @@ -45,22 +44,35 @@ export async function loader({ request, params }: LoaderFunctionArgs) {

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
if (!parsedParams.success || parsedParams.data.serviceName !== "slack") {
throw new Response("Invalid params", { status: 400 });
}

const oauthState = await OrgIntegrationRepository.consumeSlackOAuthState(
request,
parsedSearchParams.data.state,
userId
);
if (!oauthState) {
throw new Response("Invalid state", { status: 400 });
}

const service = new CreateOrgIntegrationService();

const integration = await service.call(
userId,
parsedSearchParams.data.state,
parsedParams.data.serviceName,
oauthState.organizationId,
oauthState.service,
parsedSearchParams.data.code
);

if (integration) {
return await OrgIntegrationRepository.redirectAfterAuth(request);
return await OrgIntegrationRepository.redirectAfterAuth(request, oauthState.redirectTo);
}

return redirectBackWithErrorMessage(request, "Failed to connect to the service");
return await OrgIntegrationRepository.redirectAfterAuth(
request,
oauthState.redirectTo,
"Failed to connect to the service"
);
}
Loading
Loading