Skip to content

Commit bc54ad5

Browse files
committed
Enhance session export functionality with security improvements
This commit adds security measures to the session export process in the backend, including user authentication, permission verification, and session name validation to prevent path traversal attacks. The frontend has been updated to utilize a new React Query hook for fetching export data, streamlining the export process in the session details modal. Additionally, the code has been refactored for better readability and maintainability.
1 parent aafa099 commit bc54ad5

File tree

4 files changed

+143
-58
lines changed

4 files changed

+143
-58
lines changed

components/backend/websocket/export.go

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,21 @@
22
package websocket
33

44
import (
5+
"context"
56
"encoding/json"
67
"fmt"
78
"log"
89
"net/http"
910
"os"
11+
"path/filepath"
12+
"strings"
1013
"time"
1114

15+
"ambient-code-backend/handlers"
16+
1217
"github.com/gin-gonic/gin"
18+
authv1 "k8s.io/api/authorization/v1"
19+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1320
)
1421

1522
// ExportResponse contains the exported session data
@@ -30,11 +37,57 @@ func HandleExportSession(c *gin.Context) {
3037

3138
log.Printf("Export: Exporting session %s/%s", projectName, sessionName)
3239

33-
// Build paths
34-
sessionDir := fmt.Sprintf("%s/sessions/%s", StateBaseDir, sessionName)
35-
aguiEventsPath := fmt.Sprintf("%s/agui-events.jsonl", sessionDir)
36-
legacyMigratedPath := fmt.Sprintf("%s/messages.jsonl.migrated", sessionDir)
37-
legacyOriginalPath := fmt.Sprintf("%s/messages.jsonl", sessionDir)
40+
// SECURITY: Authenticate user and get user-scoped K8s client
41+
reqK8s, _ := handlers.GetK8sClientsForRequest(c)
42+
if reqK8s == nil {
43+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
44+
c.Abort()
45+
return
46+
}
47+
48+
// SECURITY: Verify user has permission to read this session
49+
ctx := context.Background()
50+
ssar := &authv1.SelfSubjectAccessReview{
51+
Spec: authv1.SelfSubjectAccessReviewSpec{
52+
ResourceAttributes: &authv1.ResourceAttributes{
53+
Group: "vteam.ambient-code",
54+
Resource: "agenticsessions",
55+
Verb: "get",
56+
Namespace: projectName,
57+
Name: sessionName,
58+
},
59+
},
60+
}
61+
res, err := reqK8s.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, ssar, metav1.CreateOptions{})
62+
if err != nil || !res.Status.Allowed {
63+
log.Printf("Export: User not authorized to read session %s/%s", projectName, sessionName)
64+
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
65+
c.Abort()
66+
return
67+
}
68+
69+
// SECURITY: Validate sessionName to prevent path traversal
70+
if !isValidSessionName(sessionName) {
71+
log.Printf("Export: Invalid session name detected: %s", sessionName)
72+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name"})
73+
return
74+
}
75+
76+
// Build paths safely using filepath.Join and validate they're within StateBaseDir
77+
baseDir := filepath.Clean(StateBaseDir)
78+
sessionDir := filepath.Join(baseDir, "sessions", sessionName)
79+
sessionDir = filepath.Clean(sessionDir)
80+
81+
// SECURITY: Ensure path is within allowed directory (prevent path traversal)
82+
if !strings.HasPrefix(sessionDir, baseDir) {
83+
log.Printf("Export: Security - path traversal attempt detected: %s", sessionName)
84+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session name"})
85+
return
86+
}
87+
88+
aguiEventsPath := filepath.Join(sessionDir, "agui-events.jsonl")
89+
legacyMigratedPath := filepath.Join(sessionDir, "messages.jsonl.migrated")
90+
legacyOriginalPath := filepath.Join(sessionDir, "messages.jsonl")
3891

3992
// Check if session directory exists
4093
if _, err := os.Stat(sessionDir); os.IsNotExist(err) {
@@ -106,6 +159,44 @@ func HandleExportSession(c *gin.Context) {
106159
c.JSON(http.StatusOK, response)
107160
}
108161

162+
// isValidSessionName validates that the session name is a valid Kubernetes resource name
163+
// and doesn't contain path traversal characters
164+
func isValidSessionName(name string) bool {
165+
// Must not be empty
166+
if name == "" {
167+
return false
168+
}
169+
170+
// Must not contain path traversal characters
171+
if strings.Contains(name, "..") || strings.Contains(name, "/") || strings.Contains(name, "\\") {
172+
return false
173+
}
174+
175+
// Kubernetes DNS label format: lowercase alphanumeric, hyphens allowed (not at start/end)
176+
// Max 63 characters
177+
if len(name) > 63 {
178+
return false
179+
}
180+
181+
// Check each character
182+
for i, ch := range name {
183+
isLower := ch >= 'a' && ch <= 'z'
184+
isDigit := ch >= '0' && ch <= '9'
185+
isHyphen := ch == '-'
186+
187+
if !isLower && !isDigit && !isHyphen {
188+
return false
189+
}
190+
191+
// Hyphen not allowed at start or end
192+
if isHyphen && (i == 0 || i == len(name)-1) {
193+
return false
194+
}
195+
}
196+
197+
return true
198+
}
199+
109200
// readJSONLFile reads a JSONL file and returns parsed array of objects
110201
func readJSONLFile(path string) ([]map[string]interface{}, error) {
111202
data, err := os.ReadFile(path)
@@ -131,4 +222,3 @@ func readJSONLFile(path string) ([]map[string]interface{}, error) {
131222

132223
return events, nil
133224
}
134-

components/frontend/src/components/session-details-modal.tsx

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"use client";
22

3-
import { useState } from 'react';
3+
import { useCallback, useState } from 'react';
44
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
55
import { Badge } from '@/components/ui/badge';
66
import { Button } from '@/components/ui/button';
77
import { Download, Loader2 } from 'lucide-react';
88
import type { AgenticSession } from '@/types/agentic-session';
99
import { getPhaseColor } from '@/utils/session-helpers';
1010
import { successToast } from '@/hooks/use-toast';
11+
import { useSessionExport } from '@/services/queries/use-sessions';
1112

1213
function formatDuration(ms: number): string {
1314
const seconds = Math.floor(ms / 1000);
@@ -39,15 +40,6 @@ type SessionDetailsModalProps = {
3940
messageCount: number;
4041
};
4142

42-
type ExportResponse = {
43-
sessionId: string;
44-
projectName: string;
45-
exportDate: string;
46-
aguiEvents: unknown[];
47-
legacyMessages?: unknown[];
48-
hasLegacy: boolean;
49-
};
50-
5143
export function SessionDetailsModal({
5244
session,
5345
projectName,
@@ -57,57 +49,28 @@ export function SessionDetailsModal({
5749
k8sResources,
5850
messageCount,
5951
}: SessionDetailsModalProps) {
60-
const [exportData, setExportData] = useState<ExportResponse | null>(null);
61-
const [loadingExport, setLoadingExport] = useState(false);
6252
const [exportingAgui, setExportingAgui] = useState(false);
6353
const [exportingLegacy, setExportingLegacy] = useState(false);
6454
const sessionName = session.metadata?.name || '';
6555

66-
// Fetch export data when modal opens to determine what's available
67-
const fetchExportData = async () => {
68-
if (!sessionName || !projectName || exportData) return;
69-
70-
setLoadingExport(true);
71-
try {
72-
const response = await fetch(
73-
`/api/projects/${encodeURIComponent(projectName)}/agentic-sessions/${encodeURIComponent(sessionName)}/export`
74-
);
75-
76-
if (!response.ok) {
77-
const errorData = await response.json();
78-
throw new Error(errorData.error || 'Failed to load export data');
79-
}
80-
81-
const data: ExportResponse = await response.json();
82-
setExportData(data);
83-
} catch (error) {
84-
console.error('Failed to load export data:', error);
85-
} finally {
86-
setLoadingExport(false);
87-
}
88-
};
89-
90-
// Fetch export data when modal opens
91-
if (open && !exportData && !loadingExport) {
92-
fetchExportData();
93-
}
94-
95-
// Reset export data when modal closes
96-
if (!open && exportData) {
97-
setExportData(null);
98-
}
56+
// Use React Query hook - only fetches when modal is open
57+
const { data: exportData, isLoading: loadingExport } = useSessionExport(
58+
projectName,
59+
sessionName,
60+
open // Only fetch when modal is open
61+
);
9962

100-
const downloadFile = (data: unknown, filename: string) => {
63+
const downloadFile = useCallback((data: unknown, filename: string) => {
10164
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
10265
const url = URL.createObjectURL(blob);
10366
const link = document.createElement('a');
10467
link.href = url;
10568
link.download = filename;
10669
link.click();
10770
URL.revokeObjectURL(url);
108-
};
71+
}, []);
10972

110-
const handleExportAgui = async () => {
73+
const handleExportAgui = useCallback(() => {
11174
if (!exportData) return;
11275
setExportingAgui(true);
11376
try {
@@ -116,9 +79,9 @@ export function SessionDetailsModal({
11679
} finally {
11780
setExportingAgui(false);
11881
}
119-
};
82+
}, [exportData, sessionName, downloadFile]);
12083

121-
const handleExportLegacy = async () => {
84+
const handleExportLegacy = useCallback(() => {
12285
if (!exportData?.legacyMessages) return;
12386
setExportingLegacy(true);
12487
try {
@@ -127,7 +90,7 @@ export function SessionDetailsModal({
12790
} finally {
12891
setExportingLegacy(false);
12992
}
130-
};
93+
}, [exportData, sessionName, downloadFile]);
13194

13295
return (
13396
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -273,4 +236,3 @@ export function SessionDetailsModal({
273236
</Dialog>
274237
);
275238
}
276-

components/frontend/src/services/api/sessions.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,22 @@ export async function updateSessionDisplayName(
175175
{ displayName }
176176
);
177177
}
178+
179+
/**
180+
* Export session chat data
181+
*/
182+
export type SessionExportResponse = {
183+
sessionId: string;
184+
projectName: string;
185+
exportDate: string;
186+
aguiEvents: unknown[];
187+
legacyMessages?: unknown[];
188+
hasLegacy: boolean;
189+
};
190+
191+
export async function getSessionExport(
192+
projectName: string,
193+
sessionName: string
194+
): Promise<SessionExportResponse> {
195+
return apiClient.get(`/projects/${projectName}/agentic-sessions/${sessionName}/export`);
196+
}

components/frontend/src/services/queries/use-sessions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const sessionKeys = {
2525
[...sessionKeys.details(), projectName, sessionName] as const,
2626
messages: (projectName: string, sessionName: string) =>
2727
[...sessionKeys.detail(projectName, sessionName), 'messages'] as const,
28+
export: (projectName: string, sessionName: string) =>
29+
[...sessionKeys.detail(projectName, sessionName), 'export'] as const,
2830
};
2931

3032
/**
@@ -308,3 +310,15 @@ export function useUpdateSessionDisplayName() {
308310
},
309311
});
310312
}
313+
314+
/**
315+
* Hook to fetch session export data (AG-UI events + legacy messages)
316+
*/
317+
export function useSessionExport(projectName: string, sessionName: string, enabled: boolean) {
318+
return useQuery({
319+
queryKey: sessionKeys.export(projectName, sessionName),
320+
queryFn: () => sessionsApi.getSessionExport(projectName, sessionName),
321+
enabled: enabled && !!projectName && !!sessionName,
322+
staleTime: 60000, // Cache for 1 minute
323+
});
324+
}

0 commit comments

Comments
 (0)