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
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { createHmac } from "node:crypto";
import { db } from "@superset/db/client";
import { integrationConnections, usersSlackUsers } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { env } from "@/env";
import { generateConnectUrl } from "../utils/generate-connect-url";
import { createSlackClient } from "../utils/slack-client";
import { buildHomeView } from "./build-home-view";

Expand All @@ -12,25 +11,6 @@ interface ProcessAppHomeOpenedParams {
eventId: string;
}

function generateConnectUrl({
slackUserId,
teamId,
}: {
slackUserId: string;
teamId: string;
}): string {
const payload = JSON.stringify({
slackUserId,
teamId,
exp: Date.now() + 10 * 60 * 1000,
});
const signature = createHmac("sha256", env.SLACK_SIGNING_SECRET)
.update(payload)
.digest("hex");
const token = Buffer.from(payload).toString("base64url");
return `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/link?token=${token}&sig=${signature}`;
}

export async function processAppHomeOpened({
event,
teamId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { GenericMessageEvent } from "@slack/types";
import { db } from "@superset/db/client";
import { integrationConnections, usersSlackUsers } from "@superset/db/schema";
import {
integrationConnections,
subscriptions,
usersSlackUsers,
} from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { generateConnectUrl } from "../utils/generate-connect-url";
import {
formatErrorForSlack,
resolveUserMentions,
Expand Down Expand Up @@ -45,15 +50,91 @@ export async function processAssistantMessage({

const slack = createSlackClient(connection.accessToken);

const slackUserLink = event.user
? await db.query.usersSlackUsers.findFirst({
where: and(
eq(usersSlackUsers.slackUserId, event.user),
eq(usersSlackUsers.teamId, teamId),
),
columns: { modelPreference: true },
})
: undefined;
const [slackUserLink, activeSubscription] = await Promise.all([
event.user
? db.query.usersSlackUsers.findFirst({
where: and(
eq(usersSlackUsers.slackUserId, event.user),
eq(usersSlackUsers.teamId, teamId),
),
columns: { userId: true, modelPreference: true },
})
: undefined,
db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.referenceId, connection.organizationId),
eq(subscriptions.status, "active"),
),
Comment on lines +63 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check what subscription statuses exist and how trialing is handled elsewhere
rg -n "subscriptions.status" --type ts -C3 -g '!node_modules'
echo "---"
rg -n "trialing\|trial" --type ts -g '!node_modules' -g '!*.test.*' | head -40

Repository: superset-sh/superset

Length of output: 4016


🏁 Script executed:

#!/bin/bash
# Find subscription status enum/schema definition
rg -n "status.*trialing|trialing.*status" --type ts -g '!node_modules' -A2 -B2

echo "---"
# Look for subscription schema definition
rg -n "subscriptions.*=.*pgTable|type.*Subscription" --type ts -g '!node_modules' -A10 | head -80

echo "---"
# Check if there's trial-related business logic
rg -n "plan.*trial|trial.*plan|isTrial" --type ts -g '!node_modules' -g '!*.test.*' | head -20

Repository: superset-sh/superset

Length of output: 2457


🏁 Script executed:

#!/bin/bash
# Find all places where subscription status is set/updated
rg -n "status.*=.*[\"']" --type ts -g '!node_modules' | grep -i "subscription\|status" | head -30

echo "---"
# Look for Stripe subscription status handling (since status likely comes from Stripe)
rg -n "status.*complete|trial|active" --type ts -g '!node_modules' -g '!*.test.*' | grep -i "stripe\|subscription" | head -20

echo "---"
# Check the Slack integration files more closely
cat apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts | head -80

Repository: superset-sh/superset

Length of output: 7742


🏁 Script executed:

#!/bin/bash
# Find Stripe webhook/event handling for subscriptions
rg -n "stripe.*subscription|webhook.*subscription" --type ts -g '!node_modules' -i | head -20

echo "---"
# Find where subscription status is actually SET (not just queried)
rg -n "subscriptions.*update|\.status.*=|status.*pending|status.*active|status.*trialing" --type ts -g '!node_modules' -g '!*.test.*' | head -30

echo "---"
# Check if there's a plan types enum
rg -n "type.*Plan|enum.*Plan|const.*PLAN" --type ts -g '!node_modules' | head -20

Repository: superset-sh/superset

Length of output: 6778


🏁 Script executed:

#!/bin/bash
# Check the subscription schema more closely for trial-related fields
cat packages/db/src/schema/schema.ts | grep -A 30 "export const subscriptions"

echo "---"
# Check if there's any trial field in the subscription table
rg -n "trial|trialEnds\|trial_ends" --type ts -g '!node_modules' packages/db/src/schema/

echo "---"
# Look for plan handling in the subscription context
rg -n "subscription.*plan|plan.*subscription" --type ts -g '!node_modules' -B2 -A2 | head -50

Repository: superset-sh/superset

Length of output: 4609


🏁 Script executed:

#!/bin/bash
# Find Stripe webhook handlers (likely in auth/server.ts based on earlier grep)
rg -n "events.data.object.status\|customer.subscription" --type ts -g '!node_modules' -B5 -A5 | head -80

echo "---"
# Check if trials are mentioned in any business logic or guards
rg -n "trialStart|trialEnd" --type ts -g '!node_modules' -B2 -A2

Repository: superset-sh/superset

Length of output: 550


🏁 Script executed:

#!/bin/bash
# Find Stripe event type handlers - look for checkout.session.completed, invoice.payment_succeeded, etc.
rg -n "checkout\|invoice\|customer\\.subscription" --type ts packages/auth/src/server.ts -A10 -B2 | head -100

echo "---"
# Look for where subscription records are created with status values
rg -n "subscriptions\\.create\|INSERT.*subscriptions\|db\\.insert.*subscriptions" --type ts -g '!node_modules' -B3 -A10 | head -60

Repository: superset-sh/superset

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for subscription creation across entire codebase
rg -n "subscriptions.*insert\|insert.*subscriptions" --type ts -g '!node_modules' -B2 -A8

echo "---"
# Look at the full Stripe webhook file to understand event handling
find . -name "*stripe*" -type f -path "*/src/*" -not -path "*/node_modules/*" | grep -E "\.(ts|tsx)$"

Repository: superset-sh/superset

Length of output: 278


🏁 Script executed:

#!/bin/bash
# Read the stripe.ts file to understand subscription handling
wc -l packages/auth/src/stripe.ts
echo "---"
cat packages/auth/src/stripe.ts

Repository: superset-sh/superset

Length of output: 219


🏁 Script executed:

#!/bin/bash
# Get the size of server.ts
wc -l packages/auth/src/server.ts

echo "---"
# Search for Stripe event handler patterns and subscription-related updates
rg -n "export.*function\|async.*function\|stripeSubscription\|subscription.*update\|subscription.*insert" packages/auth/src/server.ts | head -40

Repository: superset-sh/superset

Length of output: 99


🏁 Script executed:

#!/bin/bash
# Look at the structure of packages/auth/src/server.ts
head -100 packages/auth/src/server.ts

echo "---"
# Search for where subscriptions table is used (insert, update, upsert)
rg -n "db.*subscriptions\|subscriptions.*\(" packages/auth/src/server.ts | head -30

Repository: superset-sh/superset

Length of output: 3382


🏁 Script executed:

#!/bin/bash
# Search for the stripe plugin configuration in server.ts
rg -n "stripe\(" packages/auth/src/server.ts -A30 | head -60

echo "---"
# Search for subscription status assignments
rg -n "status.*=\|update.*status" packages/auth/src/server.ts | grep -i subscription

Repository: superset-sh/superset

Length of output: 981


🌐 Web query:

better-auth stripe plugin subscription status values trialing active

💡 Result:

In the Better Auth Stripe plugin, subscription.status is stored as a string and effectively follows Stripe’s subscription status field. Better Auth’s own examples treat a subscription as “currently valid” if status === "active" || status === "trialing" (and it enforces at most one active or trialing subscription per referenceId at a time). [1]

So, besides "trialing" and "active", you should expect the other Stripe status values as well: incomplete, incomplete_expired, past_due, canceled, unpaid, paused. [2]

Practical rule for access checks (per Better Auth docs/examples): grant access when status is active or trialing; treat the rest as not entitled unless you intentionally support grace periods (e.g., past_due). [1][2]


🏁 Script executed:

#!/bin/bash
# Check all Slack integration files for subscription status checks
rg -n "eq\(subscriptions.status" apps/api/src/app/api/integrations/slack -B5 -A5

echo "---"
# Check if there's any trial-aware logic elsewhere
rg -n "trialing\|trial" packages/auth/src/server.ts

Repository: superset-sh/superset

Length of output: 2602


Update subscription status check to include trial subscriptions.

The query currently filters eq(subscriptions.status, "active"), which excludes trial subscriptions (status = "trialing"). According to Better Auth's Stripe plugin behavior, subscriptions can have status "trialing" during the trial period, and access logic should grant access for both "active" and "trialing" statuses.

This affects both Slack integration files:

  • apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts (line 66)
  • apps/api/src/app/api/integrations/slack/events/process-mention/process-mention.ts (line 66)

Update the query to use or(eq(subscriptions.status, "active"), eq(subscriptions.status, "trialing")) or a similar pattern to include both valid statuses.

🤖 Prompt for AI Agents
In
`@apps/api/src/app/api/integrations/slack/events/process-assistant-message/process-assistant-message.ts`
around lines 63 - 67, The subscription lookup currently only checks
eq(subscriptions.status, "active") which excludes trial users; update the where
clause used in db.query.subscriptions.findFirst (in
process-assistant-message.ts) to accept both "active" and "trialing" statuses by
replacing the single eq check with an or(eq(subscriptions.status, "active"),
eq(subscriptions.status, "trialing")) (and make the same change in the analogous
query in process-mention.ts) so both active and trialing subscriptions are
treated as valid.

columns: { id: true },
}),
]);

if (!activeSubscription) {
await slack.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: "The Superset Slack integration requires a Pro plan.",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "The Superset Slack integration requires a Pro plan.",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Upgrade to Pro", emoji: true },
url: "https://app.superset.sh/settings/billing",
style: "primary",
},
],
},
],
});
return;
}

if (!slackUserLink) {
if (!event.user) return;
const connectUrl = generateConnectUrl({
slackUserId: event.user,
teamId,
});
await slack.chat.postMessage({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: "To use Superset, you need to link your Slack account first.",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "To use Superset, you need to link your Slack account first.",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Connect Account",
emoji: true,
},
url: connectUrl,
style: "primary",
},
],
},
],
});
return;
}

const threadTs = event.thread_ts ?? event.ts;

Expand Down Expand Up @@ -84,8 +165,9 @@ export async function processAssistantMessage({
channelId: event.channel,
threadTs,
organizationId: connection.organizationId,
userId: slackUserLink.userId,
slackToken: connection.accessToken,
model: slackUserLink?.modelPreference ?? undefined,
model: slackUserLink.modelPreference ?? undefined,
onProgress: messageTs
? async (status) => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { AppMentionEvent } from "@slack/types";
import { db } from "@superset/db/client";
import { integrationConnections, usersSlackUsers } from "@superset/db/schema";
import {
integrationConnections,
subscriptions,
usersSlackUsers,
} from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { generateConnectUrl } from "../utils/generate-connect-url";
import {
formatErrorForSlack,
resolveUserMentions,
Expand Down Expand Up @@ -45,15 +50,91 @@ export async function processSlackMention({

const slack = createSlackClient(connection.accessToken);

const slackUserLink = event.user
? await db.query.usersSlackUsers.findFirst({
where: and(
eq(usersSlackUsers.slackUserId, event.user),
eq(usersSlackUsers.teamId, teamId),
),
columns: { modelPreference: true },
})
: undefined;
const [slackUserLink, activeSubscription] = await Promise.all([
event.user
? db.query.usersSlackUsers.findFirst({
where: and(
eq(usersSlackUsers.slackUserId, event.user),
eq(usersSlackUsers.teamId, teamId),
),
columns: { userId: true, modelPreference: true },
})
: undefined,
db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.referenceId, connection.organizationId),
eq(subscriptions.status, "active"),
),
columns: { id: true },
}),
]);

if (!activeSubscription) {
await slack.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: "The Superset Slack integration requires a Pro plan.",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "The Superset Slack integration requires a Pro plan.",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: { type: "plain_text", text: "Upgrade to Pro", emoji: true },
url: "https://app.superset.sh/settings/billing",
style: "primary",
},
],
},
],
});
return;
}

if (!slackUserLink) {
if (!event.user) return;
const connectUrl = generateConnectUrl({
slackUserId: event.user,
teamId,
});
await slack.chat.postMessage({
channel: event.channel,
thread_ts: event.thread_ts ?? event.ts,
text: "To use Superset, you need to link your Slack account first.",
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: "To use Superset, you need to link your Slack account first.",
},
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Connect Account",
emoji: true,
},
url: connectUrl,
style: "primary",
},
],
},
],
});
return;
}

try {
await slack.reactions.add({
Expand Down Expand Up @@ -94,8 +175,9 @@ export async function processSlackMention({
channelId: event.channel,
threadTs,
organizationId: connection.organizationId,
userId: slackUserLink.userId,
slackToken: connection.accessToken,
model: slackUserLink?.modelPreference ?? undefined,
model: slackUserLink.modelPreference ?? undefined,
onProgress: messageTs
? async (status) => {
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createHmac } from "node:crypto";
import { env } from "@/env";

export function generateConnectUrl({
slackUserId,
teamId,
}: {
slackUserId: string;
teamId: string;
}): string {
const payload = JSON.stringify({
slackUserId,
teamId,
exp: Date.now() + 10 * 60 * 1000,
});
const signature = createHmac("sha256", env.SLACK_SIGNING_SECRET)
.update(payload)
.digest("hex");
const token = Buffer.from(payload).toString("base64url");
return `${env.NEXT_PUBLIC_API_URL}/api/integrations/slack/link?token=${token}&sig=${signature}`;
}
Comment on lines +1 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for the slack/link endpoint handler
fd -t f \( -name "*.ts" -o -name "*.js" \) | xargs grep -l "slack/link" | head -20

Repository: superset-sh/superset

Length of output: 294


🏁 Script executed:

#!/bin/bash
# Search more broadly for slack integration route files
find . -type f \( -name "*.ts" -o -name "*.js" \) -path "*slack*" -path "*link*" 2>/dev/null

Repository: superset-sh/superset

Length of output: 289


🏁 Script executed:

#!/bin/bash
# Search for signature validation and expiry checks in slack-related files
rg -n "timingSafeEqual|timingConstantEqual|exp.*Date|signature.*verify" --type ts -g "*slack*" 2>/dev/null

Repository: superset-sh/superset

Length of output: 46


🏁 Script executed:

cat apps/api/src/app/api/integrations/slack/link/route.ts

Repository: superset-sh/superset

Length of output: 2704


Fix timing attack vulnerability: use crypto.timingSafeEqual for signature comparison.

The endpoint validates both signature and expiry, but the signature comparison at line 27 uses non-constant-time string equality (sig !== expectedSig), which is vulnerable to timing attacks. Replace with:

Fix signature comparison
import { timingSafeEqual } from "node:crypto";

// Instead of:
if (sig !== expectedSig) {

// Use:
try {
  timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig));
} catch {
  return new Response("Invalid signature", { status: 401 });
}

The expiry validation (lines 34–38) is correct.

🤖 Prompt for AI Agents
In
`@apps/api/src/app/api/integrations/slack/events/utils/generate-connect-url/generate-connect-url.ts`
around lines 1 - 21, Replace the non-constant-time signature comparison (the
direct string equality check of sig vs expectedSig) with crypto.timingSafeEqual:
import timingSafeEqual from "node:crypto", convert both sig and expectedSig to
Buffers of the same encoding/length, and call timingSafeEqual(Buffer.from(sig),
Buffer.from(expectedSig)) inside a try/catch so mismatched lengths or errors
return a 401 (“Invalid signature”); update the request handler that validates
the query params (the code that reads token, sig and computes expectedSig) to
use timingSafeEqual instead of `sig !== expectedSig` while keeping the existing
expiry check unchanged.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { generateConnectUrl } from "./generate-connect-url";
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import Anthropic from "@anthropic-ai/sdk";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { WebClient } from "@slack/web-api";
import { db } from "@superset/db/client";
import { integrationConnections } from "@superset/db/schema";
import { and, eq } from "drizzle-orm";
import { env } from "@/env";
import { DEFAULT_SLACK_MODEL } from "../../../constants";
import type { AgentAction } from "../slack-blocks";
Expand Down Expand Up @@ -113,6 +110,7 @@ interface RunSlackAgentParams {
channelId: string;
threadTs: string;
organizationId: string;
userId: string;
slackToken: string;
model?: string;
onProgress?: (status: string) => void | Promise<void>;
Expand Down Expand Up @@ -417,18 +415,6 @@ export async function runSlackAgent(
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
const actions: AgentAction[] = [];

const connection = await db.query.integrationConnections.findFirst({
where: and(
eq(integrationConnections.organizationId, params.organizationId),
eq(integrationConnections.provider, "slack"),
),
columns: { connectedByUserId: true },
});

if (!connection) {
throw new Error("Slack connection not found");
}

let supersetMcp: Client | null = null;
let cleanupSuperset: (() => Promise<void>) | null = null;

Expand All @@ -441,7 +427,7 @@ export async function runSlackAgent(
}),
createSupersetMcpClient({
organizationId: params.organizationId,
userId: connection.connectedByUserId,
userId: params.userId,
}),
]);

Expand All @@ -452,7 +438,7 @@ export async function runSlackAgent(
supersetMcp.listTools(),
fetchAgentContext({
mcpClient: supersetMcp,
userId: connection.connectedByUserId,
userId: params.userId,
}),
]);

Expand Down
Loading
Loading