Skip to content

Commit 1da299a

Browse files
committed
fix(grafana): route update_folder egress server-side (carry over #5082)
#5082 added a grafana update_folder tool that does SSRF-pinned fetch in its postProcess, re-introducing the client-bundle leak. Convert it to the internal API route pattern like the other update tools so the def is declarative and input-validation.server stays out of the client bundle.
1 parent d6f369f commit 1da299a

4 files changed

Lines changed: 204 additions & 84 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { grafanaUpdateFolderContract } from '@/lib/api/contracts/tools/grafana'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import {
8+
secureFetchWithPinnedIP,
9+
validateUrlWithDNS,
10+
} from '@/lib/core/security/input-validation.server'
11+
import { generateRequestId } from '@/lib/core/utils/request'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
const logger = createLogger('GrafanaUpdateFolderAPI')
17+
18+
export const POST = withRouteHandler(async (request: NextRequest) => {
19+
const requestId = generateRequestId()
20+
21+
try {
22+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
23+
24+
if (!authResult.success || !authResult.userId) {
25+
logger.warn(`[${requestId}] Unauthorized Grafana update folder attempt: ${authResult.error}`)
26+
return NextResponse.json(
27+
{ success: false, error: authResult.error || 'Authentication required' },
28+
{ status: 401 }
29+
)
30+
}
31+
32+
const parsed = await parseRequest(
33+
grafanaUpdateFolderContract,
34+
request,
35+
{},
36+
{
37+
validationErrorResponse: (error) => {
38+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
39+
return NextResponse.json(
40+
{
41+
success: false,
42+
error: getValidationErrorMessage(error, 'Invalid request data'),
43+
details: error.issues,
44+
},
45+
{ status: 400 }
46+
)
47+
},
48+
}
49+
)
50+
if (!parsed.success) return parsed.response
51+
const params = parsed.data.body
52+
53+
const baseUrl = params.baseUrl.replace(/\/$/, '')
54+
55+
const headers: Record<string, string> = {
56+
'Content-Type': 'application/json',
57+
Authorization: `Bearer ${params.apiKey}`,
58+
}
59+
if (params.organizationId) {
60+
headers['X-Grafana-Org-Id'] = params.organizationId
61+
}
62+
63+
const folderUrl = `${baseUrl}/api/folders/${params.folderUid.trim()}`
64+
const urlValidation = await validateUrlWithDNS(folderUrl, 'baseUrl')
65+
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
66+
return NextResponse.json({
67+
success: false,
68+
output: {},
69+
error: `Invalid Grafana baseUrl: ${urlValidation.error}`,
70+
})
71+
}
72+
73+
const getResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, {
74+
method: 'GET',
75+
headers,
76+
})
77+
78+
if (!getResponse.ok) {
79+
const errorText = await getResponse.text()
80+
return NextResponse.json({
81+
success: false,
82+
output: {},
83+
error: `Failed to fetch existing folder: ${errorText}`,
84+
})
85+
}
86+
87+
const existingFolder = (await getResponse.json()) as any
88+
89+
if (!existingFolder || !existingFolder.uid) {
90+
return NextResponse.json({
91+
success: false,
92+
output: {},
93+
error: 'Failed to fetch existing folder',
94+
})
95+
}
96+
97+
const body: Record<string, unknown> = {
98+
title: params.title ?? existingFolder.title,
99+
version: existingFolder.version,
100+
overwrite: true,
101+
}
102+
103+
const updateResponse = await secureFetchWithPinnedIP(folderUrl, urlValidation.resolvedIP, {
104+
method: 'PUT',
105+
headers,
106+
body: JSON.stringify(body),
107+
})
108+
109+
if (!updateResponse.ok) {
110+
const errorText = await updateResponse.text()
111+
return NextResponse.json({
112+
success: false,
113+
output: {},
114+
error: `Failed to update folder: ${errorText}`,
115+
})
116+
}
117+
118+
const data = (await updateResponse.json()) as Record<string, unknown>
119+
120+
return NextResponse.json({
121+
success: true,
122+
output: {
123+
id: (data.id as number) ?? null,
124+
uid: (data.uid as string) ?? null,
125+
title: (data.title as string) ?? null,
126+
url: (data.url as string) ?? null,
127+
parentUid: (data.parentUid as string) ?? null,
128+
parents: (data.parents as { uid: string; title: string; url: string }[]) ?? [],
129+
hasAcl: (data.hasAcl as boolean) ?? null,
130+
canSave: (data.canSave as boolean) ?? null,
131+
canEdit: (data.canEdit as boolean) ?? null,
132+
canAdmin: (data.canAdmin as boolean) ?? null,
133+
createdBy: (data.createdBy as string) ?? null,
134+
created: (data.created as string) ?? null,
135+
updatedBy: (data.updatedBy as string) ?? null,
136+
updated: (data.updated as string) ?? null,
137+
version: (data.version as number) ?? null,
138+
},
139+
})
140+
} catch (error) {
141+
logger.error(`[${requestId}] Error updating Grafana folder:`, error)
142+
return NextResponse.json({
143+
success: false,
144+
output: {},
145+
error: getErrorMessage(error),
146+
})
147+
}
148+
})

apps/sim/lib/api/contracts/tools/grafana.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,38 @@ export const grafanaUpdateAlertRuleResponseSchema = z.object({
8484
error: z.string().optional(),
8585
})
8686

87+
const grafanaUpdateFolderBodySchema = z.object({
88+
apiKey: z.string().min(1, 'Grafana Service Account Token is required'),
89+
baseUrl: z.string().min(1, 'Grafana instance URL is required'),
90+
organizationId: z.string().optional(),
91+
folderUid: z.string().min(1, 'Folder UID is required'),
92+
title: z.string().min(1, 'Folder title is required'),
93+
})
94+
95+
const grafanaUpdateFolderOutputSchema = z.object({
96+
id: z.number().nullable(),
97+
uid: z.string().nullable(),
98+
title: z.string().nullable(),
99+
url: z.string().nullable(),
100+
parentUid: z.string().nullable(),
101+
parents: z.array(z.object({ uid: z.string(), title: z.string(), url: z.string() })),
102+
hasAcl: z.boolean().nullable(),
103+
canSave: z.boolean().nullable(),
104+
canEdit: z.boolean().nullable(),
105+
canAdmin: z.boolean().nullable(),
106+
createdBy: z.string().nullable(),
107+
created: z.string().nullable(),
108+
updatedBy: z.string().nullable(),
109+
updated: z.string().nullable(),
110+
version: z.number().nullable(),
111+
})
112+
113+
export const grafanaUpdateFolderResponseSchema = z.object({
114+
success: z.boolean(),
115+
output: z.union([grafanaUpdateFolderOutputSchema, z.object({})]),
116+
error: z.string().optional(),
117+
})
118+
87119
export const grafanaUpdateDashboardContract = defineRouteContract({
88120
method: 'POST',
89121
path: '/api/tools/grafana/update_dashboard',
@@ -98,11 +130,20 @@ export const grafanaUpdateAlertRuleContract = defineRouteContract({
98130
response: { mode: 'json', schema: grafanaUpdateAlertRuleResponseSchema },
99131
})
100132

133+
export const grafanaUpdateFolderContract = defineRouteContract({
134+
method: 'POST',
135+
path: '/api/tools/grafana/update_folder',
136+
body: grafanaUpdateFolderBodySchema,
137+
response: { mode: 'json', schema: grafanaUpdateFolderResponseSchema },
138+
})
139+
101140
export {
102141
grafanaUpdateDashboardBodySchema,
103142
grafanaUpdateDashboardOutputSchema,
104143
grafanaUpdateAlertRuleBodySchema,
105144
grafanaUpdateAlertRuleOutputSchema,
145+
grafanaUpdateFolderBodySchema,
146+
grafanaUpdateFolderOutputSchema,
106147
}
107148

108149
export type GrafanaUpdateDashboardBody = ContractBody<typeof grafanaUpdateDashboardContract>
@@ -113,3 +154,5 @@ export type GrafanaUpdateAlertRuleBody = ContractBody<typeof grafanaUpdateAlertR
113154
export type GrafanaUpdateAlertRuleResponse = ContractJsonResponse<
114155
typeof grafanaUpdateAlertRuleContract
115156
>
157+
export type GrafanaUpdateFolderBody = ContractBody<typeof grafanaUpdateFolderContract>
158+
export type GrafanaUpdateFolderResponse = ContractJsonResponse<typeof grafanaUpdateFolderContract>

apps/sim/tools/grafana/update_folder.ts

Lines changed: 11 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import {
2-
secureFetchWithPinnedIP,
3-
validateUrlWithDNS,
4-
} from '@/lib/core/security/input-validation.server'
51
import type { GrafanaUpdateFolderParams } from '@/tools/grafana/types'
62
import type { ToolConfig, ToolResponse } from '@/tools/types'
73

@@ -45,90 +41,23 @@ export const updateFolderTool: ToolConfig<GrafanaUpdateFolderParams, ToolRespons
4541
},
4642

4743
request: {
48-
url: (params) => `${params.baseUrl.replace(/\/$/, '')}/api/folders/${params.folderUid.trim()}`,
49-
method: 'GET',
50-
headers: (params) => {
51-
const headers: Record<string, string> = {
52-
'Content-Type': 'application/json',
53-
Authorization: `Bearer ${params.apiKey}`,
54-
}
55-
if (params.organizationId) {
56-
headers['X-Grafana-Org-Id'] = params.organizationId
57-
}
58-
return headers
59-
},
44+
url: () => '/api/tools/grafana/update_folder',
45+
method: 'POST',
46+
headers: () => ({ 'Content-Type': 'application/json' }),
47+
body: (params) => ({
48+
apiKey: params.apiKey,
49+
baseUrl: params.baseUrl,
50+
organizationId: params.organizationId,
51+
folderUid: params.folderUid,
52+
title: params.title,
53+
}),
6054
},
6155

6256
transformResponse: async (response: Response) => {
6357
const data = await response.json()
6458
return {
6559
success: true,
66-
output: { _existingFolder: data },
67-
}
68-
},
69-
70-
postProcess: async (result, params) => {
71-
const existingFolder = result.output._existingFolder
72-
73-
if (!existingFolder || !existingFolder.uid) {
74-
return { success: false, output: {}, error: 'Failed to fetch existing folder' }
75-
}
76-
77-
const body: Record<string, unknown> = {
78-
title: params.title ?? existingFolder.title,
79-
version: existingFolder.version,
80-
overwrite: true,
81-
}
82-
83-
const headers: Record<string, string> = {
84-
'Content-Type': 'application/json',
85-
Authorization: `Bearer ${params.apiKey}`,
86-
}
87-
if (params.organizationId) {
88-
headers['X-Grafana-Org-Id'] = params.organizationId
89-
}
90-
91-
const updateUrl = `${params.baseUrl.replace(/\/$/, '')}/api/folders/${params.folderUid.trim()}`
92-
const urlValidation = await validateUrlWithDNS(updateUrl, 'baseUrl')
93-
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
94-
return {
95-
success: false,
96-
output: {},
97-
error: `Invalid Grafana baseUrl: ${urlValidation.error}`,
98-
}
99-
}
100-
101-
const updateResponse = await secureFetchWithPinnedIP(updateUrl, urlValidation.resolvedIP, {
102-
method: 'PUT',
103-
headers,
104-
body: JSON.stringify(body),
105-
})
106-
107-
if (!updateResponse.ok) {
108-
const errorText = await updateResponse.text()
109-
return { success: false, output: {}, error: `Failed to update folder: ${errorText}` }
110-
}
111-
112-
const data = (await updateResponse.json()) as Record<string, unknown>
113-
return {
114-
success: true,
115-
output: {
116-
id: (data.id as number) ?? null,
117-
uid: (data.uid as string) ?? null,
118-
title: (data.title as string) ?? null,
119-
url: (data.url as string) ?? null,
120-
parentUid: (data.parentUid as string) ?? null,
121-
parents: (data.parents as { uid: string; title: string; url: string }[]) ?? [],
122-
hasAcl: (data.hasAcl as boolean) ?? null,
123-
canSave: (data.canSave as boolean) ?? null,
124-
canEdit: (data.canEdit as boolean) ?? null,
125-
canAdmin: (data.canAdmin as boolean) ?? null,
126-
createdBy: (data.createdBy as string) ?? null,
127-
created: (data.created as string) ?? null,
128-
updatedBy: (data.updatedBy as string) ?? null,
129-
updated: (data.updated as string) ?? null,
130-
version: (data.version as number) ?? null,
131-
},
60+
output: data.output,
13261
}
13362
},
13463

scripts/check-api-validation-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
99
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
1010

1111
const BASELINE = {
12-
totalRoutes: 850,
13-
zodRoutes: 850,
12+
totalRoutes: 851,
13+
zodRoutes: 851,
1414
nonZodRoutes: 0,
1515
} as const
1616

0 commit comments

Comments
 (0)