kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: integration security and quality issues, add Discord/Slack docs

- Enforce SSRF check at config validation time for generic webhooks
- Consolidate SSRF logic into config.ts, remove duplication from client.ts
- Mask webhook URL in generic webhook API response (consistent with Discord/Slack)
- Fix double body read in github import-issues middleware
- Fix Slack frontend useEffect deps to match Discord's stable reset pattern
- Add Slack-specific URL pattern validation on frontend
- Add $onUpdate to githubIntegrationTable.updatedAt
- Add dedicated Discord and Slack integration doc pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Andrej 3953cb4a fa54c670

+255 -100
+4 -1
apps/api/src/database/schema.ts
··· 445 445 installationId: integer("installation_id"), 446 446 isActive: boolean("is_active").default(true), 447 447 createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), 448 - updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().notNull(), 448 + updatedAt: timestamp("updated_at", { mode: "date" }) 449 + .defaultNow() 450 + .$onUpdate(() => new Date()) 451 + .notNull(), 449 452 }); 450 453 451 454 export const integrationTable = pgTable(
+3 -3
apps/api/src/generic-webhook-integration/index.ts
··· 26 26 }; 27 27 }>(); 28 28 29 - function maskSecret(value: string | undefined): string | null { 29 + function maskValue(value: string | undefined): string | null { 30 30 if (!value) return null; 31 31 return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : "••••"; 32 32 } ··· 47 47 id: integration.id, 48 48 projectId: integration.projectId, 49 49 webhookConfigured: Boolean(config.webhookUrl), 50 - webhookUrl: config.webhookUrl, 50 + maskedWebhookUrl: maskValue(config.webhookUrl), 51 51 secretConfigured: Boolean(config.secret), 52 - maskedSecret: maskSecret(config.secret), 52 + maskedSecret: maskValue(config.secret), 53 53 events: { 54 54 ...defaultGenericWebhookEvents, 55 55 ...(config.events ?? {}),
+1 -2
apps/api/src/github-integration/index.ts
··· 333 333 throw new HTTPException(401, { message: "Unauthorized" }); 334 334 } 335 335 336 - const body = await c.req.json(); 337 - const projectId = body.projectId as string; 336 + const { projectId } = c.req.valid("json"); 338 337 339 338 const [project] = await db 340 339 .select({ workspaceId: projectTable.workspaceId })
+1 -79
apps/api/src/plugins/generic-webhook/client.ts
··· 1 1 import { createHmac } from "node:crypto"; 2 - import { lookup } from "node:dns/promises"; 3 - import net from "node:net"; 2 + import { assertPublicWebhookDestination } from "./config"; 4 3 5 4 type GenericWebhookPayload = Record<string, unknown>; 6 5 7 6 const GENERIC_WEBHOOK_TIMEOUT_MS = 10_000; 8 - 9 - function isDisallowedIpv4(ip: string): boolean { 10 - const parts = ip.split(".").map((part) => Number.parseInt(part, 10)); 11 - if (parts.length !== 4 || parts.some(Number.isNaN)) { 12 - return true; 13 - } 14 - 15 - const [a, b] = parts; 16 - 17 - return ( 18 - a === 0 || 19 - a === 10 || 20 - a === 127 || 21 - (a === 169 && b === 254) || 22 - (a === 172 && b >= 16 && b <= 31) || 23 - (a === 192 && b === 168) 24 - ); 25 - } 26 - 27 - function isDisallowedIpv6(ip: string): boolean { 28 - const normalized = ip.toLowerCase(); 29 - 30 - return ( 31 - normalized === "::" || 32 - normalized === "::1" || 33 - normalized.startsWith("fe8") || 34 - normalized.startsWith("fe9") || 35 - normalized.startsWith("fea") || 36 - normalized.startsWith("feb") || 37 - normalized.startsWith("fc") || 38 - normalized.startsWith("fd") 39 - ); 40 - } 41 - 42 - function isDisallowedAddress(address: string): boolean { 43 - if (address === "localhost") { 44 - return true; 45 - } 46 - 47 - const version = net.isIP(address); 48 - if (version === 4) { 49 - return isDisallowedIpv4(address); 50 - } 51 - 52 - if (version === 6) { 53 - return isDisallowedIpv6(address); 54 - } 55 - 56 - return false; 57 - } 58 - 59 - async function assertPublicWebhookDestination( 60 - webhookUrl: string, 61 - ): Promise<void> { 62 - const url = new URL(webhookUrl); 63 - 64 - if (!["http:", "https:"].includes(url.protocol)) { 65 - throw new Error("Generic webhook URL must use http or https"); 66 - } 67 - 68 - if (isDisallowedAddress(url.hostname)) { 69 - throw new Error( 70 - "Generic webhook destination resolves to a non-routable address", 71 - ); 72 - } 73 - 74 - const addresses = await lookup(url.hostname, { all: true, verbatim: true }); 75 - if (addresses.length === 0) { 76 - throw new Error("Generic webhook destination could not be resolved"); 77 - } 78 - 79 - if (addresses.some((entry) => isDisallowedAddress(entry.address))) { 80 - throw new Error( 81 - "Generic webhook destination resolves to a non-routable address", 82 - ); 83 - } 84 - } 85 7 86 8 export async function postToGenericWebhook( 87 9 webhookUrl: string,
+81 -1
apps/api/src/plugins/generic-webhook/config.ts
··· 1 + import { lookup } from "node:dns/promises"; 2 + import net from "node:net"; 1 3 import * as v from "valibot"; 4 + 5 + function isDisallowedIpv4(ip: string): boolean { 6 + const parts = ip.split(".").map((part) => Number.parseInt(part, 10)); 7 + if (parts.length !== 4 || parts.some(Number.isNaN)) { 8 + return true; 9 + } 10 + 11 + const [a, b] = parts; 12 + 13 + return ( 14 + a === 0 || 15 + a === 10 || 16 + a === 127 || 17 + (a === 169 && b === 254) || 18 + (a === 172 && b >= 16 && b <= 31) || 19 + (a === 192 && b === 168) 20 + ); 21 + } 22 + 23 + function isDisallowedIpv6(ip: string): boolean { 24 + const normalized = ip.toLowerCase(); 25 + 26 + return ( 27 + normalized === "::" || 28 + normalized === "::1" || 29 + normalized.startsWith("fe8") || 30 + normalized.startsWith("fe9") || 31 + normalized.startsWith("fea") || 32 + normalized.startsWith("feb") || 33 + normalized.startsWith("fc") || 34 + normalized.startsWith("fd") 35 + ); 36 + } 37 + 38 + function isDisallowedAddress(address: string): boolean { 39 + if (address === "localhost") { 40 + return true; 41 + } 42 + 43 + const version = net.isIP(address); 44 + if (version === 4) { 45 + return isDisallowedIpv4(address); 46 + } 47 + 48 + if (version === 6) { 49 + return isDisallowedIpv6(address); 50 + } 51 + 52 + return false; 53 + } 54 + 55 + export async function assertPublicWebhookDestination( 56 + webhookUrl: string, 57 + ): Promise<void> { 58 + const url = new URL(webhookUrl); 59 + 60 + if (!["http:", "https:"].includes(url.protocol)) { 61 + throw new Error("Generic webhook URL must use http or https"); 62 + } 63 + 64 + if (isDisallowedAddress(url.hostname)) { 65 + throw new Error( 66 + "Generic webhook destination resolves to a non-routable address", 67 + ); 68 + } 69 + 70 + const addresses = await lookup(url.hostname, { all: true, verbatim: true }); 71 + if (addresses.length === 0) { 72 + throw new Error("Generic webhook destination could not be resolved"); 73 + } 74 + 75 + if (addresses.some((entry) => isDisallowedAddress(entry.address))) { 76 + throw new Error( 77 + "Generic webhook destination resolves to a non-routable address", 78 + ); 79 + } 80 + } 2 81 3 82 export const genericWebhookEventKeys = [ 4 83 "taskCreated", ··· 93 172 config: unknown, 94 173 ): Promise<{ valid: boolean; errors?: string[] }> { 95 174 try { 96 - v.parse(genericWebhookConfigSchema, config); 175 + const parsed = v.parse(genericWebhookConfigSchema, config); 176 + await assertPublicWebhookDestination(parsed.webhookUrl); 97 177 return { valid: true }; 98 178 } catch (error) { 99 179 if (error instanceof v.ValiError) {
+68
apps/docs/core/integrations/discord.mdx
··· 1 + --- 2 + title: Discord 3 + description: Send task notifications from Kaneo to a Discord channel using incoming webhooks. 4 + --- 5 + 6 + Kaneo can post task activity to a Discord channel whenever events occur in a project. This uses Discord's incoming webhook feature — no bot or OAuth setup required. 7 + 8 + ## Create a Discord webhook 9 + 10 + 1. Open Discord and go to the channel you want notifications in. 11 + 2. Click the gear icon to open **Channel Settings**. 12 + 3. Go to **Integrations** then **Webhooks**. 13 + 4. Click **New Webhook**. 14 + 5. Give the webhook a name (e.g. "Kaneo") and optionally set an avatar. 15 + 6. Click **Copy Webhook URL**. It will look like: 16 + 17 + ``` 18 + https://discord.com/api/webhooks/000000000000000000/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 19 + ``` 20 + 21 + <Info> 22 + URLs using `discordapp.com` are also accepted. 23 + </Info> 24 + 25 + ## Connect Discord in Kaneo 26 + 27 + 1. Open **Settings** in the sidebar. 28 + 2. Under **Projects**, select the project you want to connect. 29 + 3. Open the **Integrations** tab. 30 + 4. Expand the **Discord** section. 31 + 5. Paste your webhook URL. 32 + 6. Optionally add a channel name label for your own reference. 33 + 7. Choose which events to subscribe to (see below). 34 + 8. Click **Connect**. 35 + 36 + Once connected you can enable or disable the integration with the toggle, update event subscriptions, or disconnect entirely. 37 + 38 + ## Supported events 39 + 40 + | Event | Default | Description | 41 + | --- | --- | --- | 42 + | Task created | On | A new task is created in the project | 43 + | Task status changed | On | A task moves to a different status column | 44 + | Task priority changed | Off | A task's priority is changed | 45 + | Task title changed | Off | A task's title is edited | 46 + | Task description changed | Off | A task's description is edited | 47 + | Task comment created | On | A new comment is added to a task | 48 + 49 + You can toggle any combination of these events in the integration settings at any time. 50 + 51 + ## Message format 52 + 53 + Kaneo sends Discord embed messages with: 54 + 55 + - A title describing the event (e.g. "Task Status Changed") 56 + - Task name with a clickable link to the task in Kaneo 57 + - Fields for project, status, and priority 58 + - The name of the user who triggered the event 59 + 60 + Make sure [`KANEO_CLIENT_URL`](/core/installation/environment-variables) is set to your public Kaneo URL so task links work correctly. 61 + 62 + ## Updating the webhook 63 + 64 + To change the webhook URL after connecting, paste the new URL in the webhook field and click **Save Changes**. If you leave the field empty, the existing URL is kept. 65 + 66 + ## Disconnecting 67 + 68 + Click **Disconnect** to remove the integration. This deletes the stored webhook URL and all event settings for that project. You can reconnect at any time by pasting a new webhook URL.
+68
apps/docs/core/integrations/slack.mdx
··· 1 + --- 2 + title: Slack 3 + description: Send task notifications from Kaneo to a Slack channel using incoming webhooks. 4 + --- 5 + 6 + Kaneo can post task activity to a Slack channel whenever events occur in a project. This uses Slack's incoming webhook feature — no bot token or OAuth flow required. 7 + 8 + ## Create a Slack webhook 9 + 10 + 1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** then **From scratch**. 11 + 2. Name your app (e.g. "Kaneo") and select your workspace. 12 + 3. In the app settings, go to **Incoming Webhooks** and toggle it **On**. 13 + 4. Click **Add New Webhook to Workspace**. 14 + 5. Select the channel where you want notifications (or choose your own DM for personal notifications). 15 + 6. Click **Allow**, then copy the webhook URL. It will look like: 16 + 17 + ``` 18 + https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX 19 + ``` 20 + 21 + <Warning> 22 + If your Slack workspace restricts app installations, you may need a workspace admin to approve the app before you can use the webhook. 23 + </Warning> 24 + 25 + ## Connect Slack in Kaneo 26 + 27 + 1. Open **Settings** in the sidebar. 28 + 2. Under **Projects**, select the project you want to connect. 29 + 3. Open the **Integrations** tab. 30 + 4. Expand the **Slack** section. 31 + 5. Paste your webhook URL. 32 + 6. Optionally add a channel name label for your own reference. 33 + 7. Choose which events to subscribe to (see below). 34 + 8. Click **Connect**. 35 + 36 + Once connected you can enable or disable the integration with the toggle, update event subscriptions, or disconnect entirely. 37 + 38 + ## Supported events 39 + 40 + | Event | Default | Description | 41 + | --- | --- | --- | 42 + | Task created | On | A new task is created in the project | 43 + | Task status changed | On | A task moves to a different status column | 44 + | Task priority changed | Off | A task's priority is changed | 45 + | Task title changed | Off | A task's title is edited | 46 + | Task description changed | Off | A task's description is edited | 47 + | Task comment created | On | A new comment is added to a task | 48 + 49 + You can toggle any combination of these events in the integration settings at any time. 50 + 51 + ## Message format 52 + 53 + Kaneo sends Slack Block Kit messages with: 54 + 55 + - A header describing the event (e.g. "Task Status Changed") 56 + - Task name with a clickable link to the task in Kaneo 57 + - Fields for project, status, and priority 58 + - The name of the user who triggered the event 59 + 60 + Make sure [`KANEO_CLIENT_URL`](/core/installation/environment-variables) is set to your public Kaneo URL so task links work correctly. 61 + 62 + ## Updating the webhook 63 + 64 + To change the webhook URL after connecting, paste the new URL in the webhook field and click **Save Changes**. If you leave the field empty, the existing URL is kept. 65 + 66 + ## Disconnecting 67 + 68 + Click **Disconnect** to remove the integration. This deletes the stored webhook URL and all event settings for that project. You can reconnect at any time by pasting a new webhook URL.
+6 -2
apps/docs/docs.json
··· 80 80 ] 81 81 }, 82 82 { 83 - "group": "Outgoing webhooks", 84 - "pages": ["core/integrations/outgoing-webhooks"] 83 + "group": "Integrations", 84 + "pages": [ 85 + "core/integrations/discord", 86 + "core/integrations/slack", 87 + "core/integrations/outgoing-webhooks" 88 + ] 85 89 }, 86 90 { 87 91 "group": "Authentication Providers",
+1 -1
apps/web/src/components/project/generic-webhook-integration-settings.tsx
··· 104 104 105 105 const normalizedValues = React.useMemo<GenericWebhookIntegrationFormValues>( 106 106 () => ({ 107 - webhookUrl: integration?.webhookUrl ?? "", 107 + webhookUrl: "", 108 108 secret: "", 109 109 taskCreated: integration?.events?.taskCreated ?? true, 110 110 taskStatusChanged: integration?.events?.taskStatusChanged ?? true,
+21 -10
apps/web/src/components/project/slack-integration-settings.tsx
··· 70 70 ); 71 71 } 72 72 73 + function isValidSlackWebhookUrl(value: string): boolean { 74 + return ( 75 + z.url().safeParse(value).success && 76 + /^https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9]+\/[A-Za-z0-9]+\/[A-Za-z0-9]+$/i.test( 77 + value, 78 + ) 79 + ); 80 + } 81 + 73 82 export function SlackIntegrationSettings({ projectId }: { projectId: string }) { 74 83 const { t } = useTranslation(); 75 84 const schema = React.useMemo( ··· 122 131 taskCommentCreated: true, 123 132 }, 124 133 }); 134 + const { reset } = form; 135 + const lastResetKeyRef = React.useRef<string | null>(null); 136 + const resetKey = `${projectId}:${integration?.id ?? "none"}`; 125 137 126 138 React.useEffect(() => { 127 - form.reset(normalizedValues); 128 - }, [form, normalizedValues]); 139 + if (form.formState.isDirty && lastResetKeyRef.current === resetKey) { 140 + return; 141 + } 142 + 143 + reset(normalizedValues); 144 + lastResetKeyRef.current = resetKey; 145 + }, [form.formState.isDirty, normalizedValues, reset, resetKey]); 129 146 130 147 const isConnected = Boolean(integration?.webhookConfigured); 131 148 const isBusy = isCreating || isUpdating || isDeleting; ··· 143 160 }; 144 161 145 162 if (!isConnected) { 146 - if ( 147 - !trimmedWebhookUrl || 148 - !z.url().safeParse(trimmedWebhookUrl).success 149 - ) { 163 + if (!trimmedWebhookUrl || !isValidSlackWebhookUrl(trimmedWebhookUrl)) { 150 164 form.setError("webhookUrl", { 151 165 message: t("settings:slackIntegration.validation.webhookInvalid"), 152 166 }); ··· 162 176 }, 163 177 }); 164 178 } else { 165 - if ( 166 - trimmedWebhookUrl && 167 - !z.url().safeParse(trimmedWebhookUrl).success 168 - ) { 179 + if (trimmedWebhookUrl && !isValidSlackWebhookUrl(trimmedWebhookUrl)) { 169 180 form.setError("webhookUrl", { 170 181 message: t("settings:slackIntegration.validation.webhookInvalid"), 171 182 });
+1 -1
apps/web/src/fetchers/generic-webhook-integration/get-generic-webhook-integration.ts
··· 4 4 id: string; 5 5 projectId: string; 6 6 webhookConfigured: boolean; 7 - webhookUrl: string; 7 + maskedWebhookUrl: string | null; 8 8 secretConfigured: boolean; 9 9 maskedSecret: string | null; 10 10 events: {