Skip to content

Commit 4c80189

Browse files
committed
fix(jupyter): route all 15 remaining tools through an internal proxy for HTTP/private-host support and no redirects
The generic external tool executor blocks plain-HTTP and non-localhost private-IP hosts by default, so every non-upload Jupyter operation could fail against typical self-hosted setups (LAN IP, docker hostname, or even literal localhost on a hosted deployment) even though the upload route worked via its own internal route. Added /api/tools/jupyter/proxy (DNS-pinned, allowHttp, maxRedirects: 0) that mirrors the upstream Jupyter response verbatim, matching the established pattern for self-hosted-arbitrary-host integrations (Grafana, 1Password) instead of the generic executor path. Each tool's request block now posts to the proxy instead of building a direct external URL; transformResponse and outputs are unchanged since the proxy response mirrors upstream status/body exactly. Also switches the upload route from stripAuthOnRedirect to maxRedirects: 0 — stronger, since it stops the uploaded file body (not just the token) from ever reaching a redirect target.
1 parent c6afd79 commit 4c80189

19 files changed

Lines changed: 267 additions & 165 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { jupyterProxyContract } from '@/lib/api/contracts/tools/jupyter'
5+
import { 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+
import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils'
14+
15+
export const dynamic = 'force-dynamic'
16+
17+
const logger = createLogger('JupyterProxyAPI')
18+
19+
/**
20+
* Proxies Contents/Kernels/Kernelspecs/Sessions API calls to a self-hosted
21+
* Jupyter server. Self-hosted servers have no fixed public host, so every
22+
* request is server-side (DNS-pinned, http(s) allowed, redirects disabled)
23+
* rather than going through the generic external tool executor, which blocks
24+
* plain-HTTP and private-IP hosts by default. Mirrors the upstream status
25+
* and body verbatim so callers can treat this exactly like a direct fetch.
26+
*/
27+
export const POST = withRouteHandler(async (request: NextRequest) => {
28+
const requestId = generateRequestId()
29+
30+
try {
31+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
32+
if (!authResult.success || !authResult.userId) {
33+
logger.warn(`[${requestId}] Unauthorized Jupyter proxy attempt: ${authResult.error}`)
34+
return NextResponse.json(
35+
{ success: false, error: authResult.error || 'Authentication required' },
36+
{ status: 401 }
37+
)
38+
}
39+
40+
const parsed = await parseRequest(jupyterProxyContract, request, {})
41+
if (!parsed.success) return parsed.response
42+
const data = parsed.data.body
43+
44+
const base = normalizeJupyterServerUrl(data.serverUrl)
45+
const url = `${base}/api/${data.path}`
46+
47+
const urlValidation = await validateUrlWithDNS(url, 'serverUrl', { allowHttp: true })
48+
if (!urlValidation.isValid || !urlValidation.resolvedIP) {
49+
return NextResponse.json(
50+
{ success: false, error: `Invalid Jupyter serverUrl: ${urlValidation.error}` },
51+
{ status: 400 }
52+
)
53+
}
54+
55+
const hasBody = data.body !== undefined && data.body !== null
56+
57+
const upstream = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP, {
58+
method: data.method,
59+
headers: {
60+
...buildJupyterAuthHeaders(data.token),
61+
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
62+
},
63+
body: hasBody ? JSON.stringify(data.body) : undefined,
64+
allowHttp: true,
65+
maxRedirects: 0,
66+
})
67+
68+
const text = await upstream.text()
69+
70+
return new NextResponse(text.length > 0 ? text : null, {
71+
status: upstream.status,
72+
headers: { 'Content-Type': upstream.headers.get('content-type') || 'application/json' },
73+
})
74+
} catch (error) {
75+
logger.error(`[${requestId}] Unexpected error:`, error)
76+
return NextResponse.json(
77+
{ success: false, error: getErrorMessage(error, 'Unknown error') },
78+
{ status: 500 }
79+
)
80+
}
81+
})

apps/sim/app/api/tools/jupyter/upload/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
109109
content: fileBuffer.toString('base64'),
110110
}),
111111
allowHttp: true,
112-
stripAuthOnRedirect: true,
112+
maxRedirects: 0,
113113
})
114114

115115
if (!response.ok) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { z } from 'zod'
2+
import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types'
3+
import { defineRouteContract } from '@/lib/api/contracts/types'
4+
5+
export const jupyterProxyBodySchema = z.object({
6+
serverUrl: z.string().min(1, 'Server URL is required'),
7+
token: z.string().min(1, 'Token is required'),
8+
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
9+
path: z.string().min(1, 'Path is required'),
10+
body: z.unknown().optional().nullable(),
11+
})
12+
13+
export const jupyterProxyContract = defineRouteContract({
14+
method: 'POST',
15+
path: '/api/tools/jupyter/proxy',
16+
body: jupyterProxyBodySchema,
17+
// untyped-response: the route mirrors the upstream Jupyter server's response
18+
// verbatim (status + body), which varies per Contents/Kernels/Sessions endpoint
19+
response: { mode: 'json', schema: z.unknown() },
20+
})
21+
22+
export type JupyterProxyBody = ContractBodyInput<typeof jupyterProxyContract>
23+
export type JupyterProxyResponse = ContractJsonResponse<typeof jupyterProxyContract>

apps/sim/tools/jupyter/copy_content.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import type { JupyterCopyContentParams, JupyterCopyContentResponse } from '@/tools/jupyter/types'
2-
import {
3-
assertSafeJupyterPath,
4-
buildJupyterAuthHeaders,
5-
encodeJupyterPath,
6-
normalizeJupyterServerUrl,
7-
} from '@/tools/jupyter/utils'
2+
import { assertSafeJupyterPath, encodeJupyterPath } from '@/tools/jupyter/utils'
83
import type { ToolConfig } from '@/tools/types'
94

105
export const jupyterCopyContentTool: ToolConfig<
@@ -44,17 +39,16 @@ export const jupyterCopyContentTool: ToolConfig<
4439
},
4540

4641
request: {
47-
url: (params) => {
48-
const base = normalizeJupyterServerUrl(params.serverUrl)
49-
const path = encodeJupyterPath(params.path)
50-
return `${base}/api/contents/${path}`
51-
},
42+
url: '/api/tools/jupyter/proxy',
5243
method: 'POST',
53-
headers: (params) => ({
54-
...buildJupyterAuthHeaders(params.token),
55-
'Content-Type': 'application/json',
44+
headers: () => ({ 'Content-Type': 'application/json' }),
45+
body: (params) => ({
46+
serverUrl: params.serverUrl,
47+
token: params.token,
48+
method: 'POST',
49+
path: `contents/${encodeJupyterPath(params.path)}`,
50+
body: { copy_from: assertSafeJupyterPath(params.copyFromPath) },
5651
}),
57-
body: (params) => ({ copy_from: assertSafeJupyterPath(params.copyFromPath) }),
5852
},
5953

6054
transformResponse: async (response, params) => {

apps/sim/tools/jupyter/create_file.ts

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { JupyterCreateFileParams, JupyterCreateFileResponse } from '@/tools/jupyter/types'
2-
import {
3-
buildJupyterAuthHeaders,
4-
encodeJupyterPath,
5-
normalizeJupyterServerUrl,
6-
} from '@/tools/jupyter/utils'
2+
import { encodeJupyterPath } from '@/tools/jupyter/utils'
73
import type { ToolConfig } from '@/tools/types'
84

95
const EMPTY_NOTEBOOK = { cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 }
@@ -50,35 +46,36 @@ export const jupyterCreateFileTool: ToolConfig<JupyterCreateFileParams, JupyterC
5046
},
5147

5248
request: {
53-
url: (params) => {
54-
const base = normalizeJupyterServerUrl(params.serverUrl)
55-
const path = encodeJupyterPath(params.path)
56-
return `${base}/api/contents/${path}`
57-
},
58-
method: 'PUT',
59-
headers: (params) => ({
60-
...buildJupyterAuthHeaders(params.token),
61-
'Content-Type': 'application/json',
62-
}),
49+
url: '/api/tools/jupyter/proxy',
50+
method: 'POST',
51+
headers: () => ({ 'Content-Type': 'application/json' }),
6352
body: (params) => {
53+
let content: Record<string, unknown>
6454
if (params.type === 'directory') {
65-
return { type: 'directory' }
66-
}
67-
68-
if (params.type === 'notebook') {
55+
content = { type: 'directory' }
56+
} else if (params.type === 'notebook') {
6957
if (!params.content) {
70-
return { type: 'notebook', format: 'json', content: EMPTY_NOTEBOOK }
58+
content = { type: 'notebook', format: 'json', content: EMPTY_NOTEBOOK }
59+
} else {
60+
let notebook: unknown
61+
try {
62+
notebook = JSON.parse(params.content)
63+
} catch {
64+
throw new Error('Notebook content must be valid JSON-stringified nbformat')
65+
}
66+
content = { type: 'notebook', format: 'json', content: notebook }
7167
}
72-
let content: unknown
73-
try {
74-
content = JSON.parse(params.content)
75-
} catch {
76-
throw new Error('Notebook content must be valid JSON-stringified nbformat')
77-
}
78-
return { type: 'notebook', format: 'json', content }
68+
} else {
69+
content = { type: 'file', format: 'text', content: params.content ?? '' }
7970
}
8071

81-
return { type: 'file', format: 'text', content: params.content ?? '' }
72+
return {
73+
serverUrl: params.serverUrl,
74+
token: params.token,
75+
method: 'PUT',
76+
path: `contents/${encodeJupyterPath(params.path)}`,
77+
body: content,
78+
}
8279
},
8380
},
8481

apps/sim/tools/jupyter/create_session.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ import type {
22
JupyterCreateSessionParams,
33
JupyterCreateSessionResponse,
44
} from '@/tools/jupyter/types'
5-
import {
6-
assertSafeJupyterPath,
7-
buildJupyterAuthHeaders,
8-
mapJupyterSession,
9-
normalizeJupyterServerUrl,
10-
} from '@/tools/jupyter/utils'
5+
import { assertSafeJupyterPath, mapJupyterSession } from '@/tools/jupyter/utils'
116
import type { ToolConfig } from '@/tools/types'
127

138
export const jupyterCreateSessionTool: ToolConfig<
@@ -59,17 +54,20 @@ export const jupyterCreateSessionTool: ToolConfig<
5954
},
6055

6156
request: {
62-
url: (params) => `${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions`,
57+
url: '/api/tools/jupyter/proxy',
6358
method: 'POST',
64-
headers: (params) => ({
65-
...buildJupyterAuthHeaders(params.token),
66-
'Content-Type': 'application/json',
67-
}),
59+
headers: () => ({ 'Content-Type': 'application/json' }),
6860
body: (params) => ({
69-
path: assertSafeJupyterPath(params.path),
70-
name: params.name,
71-
type: params.type || 'notebook',
72-
...(params.kernelName ? { kernel: { name: params.kernelName } } : {}),
61+
serverUrl: params.serverUrl,
62+
token: params.token,
63+
method: 'POST',
64+
path: 'sessions',
65+
body: {
66+
path: assertSafeJupyterPath(params.path),
67+
name: params.name,
68+
type: params.type || 'notebook',
69+
...(params.kernelName ? { kernel: { name: params.kernelName } } : {}),
70+
},
7371
}),
7472
},
7573

apps/sim/tools/jupyter/delete_content.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import type {
22
JupyterDeleteContentParams,
33
JupyterDeleteContentResponse,
44
} from '@/tools/jupyter/types'
5-
import {
6-
buildJupyterAuthHeaders,
7-
encodeJupyterPath,
8-
normalizeJupyterServerUrl,
9-
} from '@/tools/jupyter/utils'
5+
import { encodeJupyterPath } from '@/tools/jupyter/utils'
106
import type { ToolConfig } from '@/tools/types'
117

128
export const jupyterDeleteContentTool: ToolConfig<
@@ -40,13 +36,15 @@ export const jupyterDeleteContentTool: ToolConfig<
4036
},
4137

4238
request: {
43-
url: (params) => {
44-
const base = normalizeJupyterServerUrl(params.serverUrl)
45-
const path = encodeJupyterPath(params.path)
46-
return `${base}/api/contents/${path}`
47-
},
48-
method: 'DELETE',
49-
headers: (params) => buildJupyterAuthHeaders(params.token),
39+
url: '/api/tools/jupyter/proxy',
40+
method: 'POST',
41+
headers: () => ({ 'Content-Type': 'application/json' }),
42+
body: (params) => ({
43+
serverUrl: params.serverUrl,
44+
token: params.token,
45+
method: 'DELETE',
46+
path: `contents/${encodeJupyterPath(params.path)}`,
47+
}),
5048
},
5149

5250
transformResponse: async (response, params) => {

apps/sim/tools/jupyter/delete_session.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
JupyterDeleteSessionParams,
33
JupyterDeleteSessionResponse,
44
} from '@/tools/jupyter/types'
5-
import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils'
65
import type { ToolConfig } from '@/tools/types'
76

87
export const jupyterDeleteSessionTool: ToolConfig<
@@ -36,10 +35,15 @@ export const jupyterDeleteSessionTool: ToolConfig<
3635
},
3736

3837
request: {
39-
url: (params) =>
40-
`${normalizeJupyterServerUrl(params.serverUrl)}/api/sessions/${encodeURIComponent(params.sessionId)}`,
41-
method: 'DELETE',
42-
headers: (params) => buildJupyterAuthHeaders(params.token),
38+
url: '/api/tools/jupyter/proxy',
39+
method: 'POST',
40+
headers: () => ({ 'Content-Type': 'application/json' }),
41+
body: (params) => ({
42+
serverUrl: params.serverUrl,
43+
token: params.token,
44+
method: 'DELETE',
45+
path: `sessions/${encodeURIComponent(params.sessionId)}`,
46+
}),
4347
},
4448

4549
transformResponse: async (response, params) => {

apps/sim/tools/jupyter/get_content.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import type { JupyterGetContentParams, JupyterGetContentResponse } from '@/tools/jupyter/types'
2-
import {
3-
buildJupyterAuthHeaders,
4-
encodeJupyterPath,
5-
normalizeJupyterServerUrl,
6-
} from '@/tools/jupyter/utils'
2+
import { encodeJupyterPath } from '@/tools/jupyter/utils'
73
import type { ToolConfig } from '@/tools/types'
84

95
export const jupyterGetContentTool: ToolConfig<JupyterGetContentParams, JupyterGetContentResponse> =
@@ -35,13 +31,15 @@ export const jupyterGetContentTool: ToolConfig<JupyterGetContentParams, JupyterG
3531
},
3632

3733
request: {
38-
url: (params) => {
39-
const base = normalizeJupyterServerUrl(params.serverUrl)
40-
const path = encodeJupyterPath(params.path)
41-
return `${base}/api/contents/${path}?content=1`
42-
},
43-
method: 'GET',
44-
headers: (params) => buildJupyterAuthHeaders(params.token),
34+
url: '/api/tools/jupyter/proxy',
35+
method: 'POST',
36+
headers: () => ({ 'Content-Type': 'application/json' }),
37+
body: (params) => ({
38+
serverUrl: params.serverUrl,
39+
token: params.token,
40+
method: 'GET',
41+
path: `contents/${encodeJupyterPath(params.path)}?content=1`,
42+
}),
4543
},
4644

4745
transformResponse: async (response, params) => {

apps/sim/tools/jupyter/interrupt_kernel.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
JupyterInterruptKernelParams,
33
JupyterInterruptKernelResponse,
44
} from '@/tools/jupyter/types'
5-
import { buildJupyterAuthHeaders, normalizeJupyterServerUrl } from '@/tools/jupyter/utils'
65
import type { ToolConfig } from '@/tools/types'
76

87
export const jupyterInterruptKernelTool: ToolConfig<
@@ -36,10 +35,15 @@ export const jupyterInterruptKernelTool: ToolConfig<
3635
},
3736

3837
request: {
39-
url: (params) =>
40-
`${normalizeJupyterServerUrl(params.serverUrl)}/api/kernels/${encodeURIComponent(params.kernelId)}/interrupt`,
38+
url: '/api/tools/jupyter/proxy',
4139
method: 'POST',
42-
headers: (params) => buildJupyterAuthHeaders(params.token),
40+
headers: () => ({ 'Content-Type': 'application/json' }),
41+
body: (params) => ({
42+
serverUrl: params.serverUrl,
43+
token: params.token,
44+
method: 'POST',
45+
path: `kernels/${encodeURIComponent(params.kernelId)}/interrupt`,
46+
}),
4347
},
4448

4549
transformResponse: async (response, params) => {

0 commit comments

Comments
 (0)