kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { lookup } from "node:dns/promises";
2import net from "node:net";
3import * as v from "valibot";
4
5function 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
23function 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
38function 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
55export 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}
81
82export const genericWebhookEventKeys = [
83 "taskCreated",
84 "taskStatusChanged",
85 "taskPriorityChanged",
86 "taskTitleChanged",
87 "taskDescriptionChanged",
88 "taskCommentCreated",
89] as const;
90
91export type GenericWebhookEventKey = (typeof genericWebhookEventKeys)[number];
92
93export const genericWebhookConfigSchema = v.object({
94 webhookUrl: v.pipe(
95 v.string(),
96 v.url(),
97 v.check((value) => {
98 const protocol = new URL(value).protocol;
99 return protocol === "http:" || protocol === "https:";
100 }, "Webhook URL must use http or https"),
101 ),
102 secret: v.optional(v.string()),
103 health: v.optional(
104 v.object({
105 lastSuccessAt: v.optional(v.string()),
106 lastFailureAt: v.optional(v.string()),
107 lastFailureMessage: v.optional(v.string()),
108 failureCount: v.optional(v.number()),
109 lastAttempt: v.optional(
110 v.object({
111 eventName: v.string(),
112 taskId: v.string(),
113 projectId: v.string(),
114 webhookUrl: v.string(),
115 }),
116 ),
117 }),
118 ),
119 events: v.optional(
120 v.object({
121 taskCreated: v.optional(v.boolean()),
122 taskStatusChanged: v.optional(v.boolean()),
123 taskPriorityChanged: v.optional(v.boolean()),
124 taskTitleChanged: v.optional(v.boolean()),
125 taskDescriptionChanged: v.optional(v.boolean()),
126 taskCommentCreated: v.optional(v.boolean()),
127 }),
128 ),
129});
130
131export type GenericWebhookConfig = v.InferOutput<
132 typeof genericWebhookConfigSchema
133>;
134
135export const defaultGenericWebhookEvents: Record<
136 GenericWebhookEventKey,
137 boolean
138> = {
139 taskCreated: true,
140 taskStatusChanged: true,
141 taskPriorityChanged: false,
142 taskTitleChanged: false,
143 taskDescriptionChanged: false,
144 taskCommentCreated: true,
145};
146
147export function normalizeGenericWebhookConfig(
148 config: GenericWebhookConfig,
149): GenericWebhookConfig {
150 const secret =
151 typeof config.secret === "string"
152 ? config.secret.trim() || undefined
153 : undefined;
154
155 return {
156 ...config,
157 secret,
158 health: config.health
159 ? {
160 ...config.health,
161 failureCount: config.health.failureCount ?? 0,
162 }
163 : undefined,
164 events: {
165 ...defaultGenericWebhookEvents,
166 ...(config.events ?? {}),
167 },
168 };
169}
170
171export async function validateGenericWebhookConfig(
172 config: unknown,
173): Promise<{ valid: boolean; errors?: string[] }> {
174 try {
175 const parsed = v.parse(genericWebhookConfigSchema, config);
176 await assertPublicWebhookDestination(parsed.webhookUrl);
177 return { valid: true };
178 } catch (error) {
179 if (error instanceof v.ValiError) {
180 return {
181 valid: false,
182 errors: error.issues.map((issue) => issue.message),
183 };
184 }
185
186 return {
187 valid: false,
188 errors: [error instanceof Error ? error.message : "Invalid config"],
189 };
190 }
191}