Skip to content

Commit 050d00c

Browse files
committed
fix: copy operation edge cases and error reporting
- Fix symlink path validation when destination is root (containment check) - Return proper errors from handleCopyTo/handleCopyFrom for failed operations - Use cpErrorSent wrapper to avoid duplicate error messages to client - Detect incomplete transfers when WebSocket closes without end message
1 parent 06f8b9b commit 050d00c

File tree

2 files changed

+39
-9
lines changed

2 files changed

+39
-9
lines changed

cmd/api/api/cp.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package api
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"fmt"
78
"io"
89
"net/http"
@@ -15,6 +16,15 @@ import (
1516
mw "github.com/onkernel/hypeman/lib/middleware"
1617
)
1718

19+
// cpErrorSent wraps an error that has already been sent to the client.
20+
// The caller should log this error but not send it again to avoid duplicates.
21+
type cpErrorSent struct {
22+
err error
23+
}
24+
25+
func (e *cpErrorSent) Error() string { return e.err.Error() }
26+
func (e *cpErrorSent) Unwrap() error { return e.err }
27+
1828
// CpRequest represents the JSON body for copy requests
1929
type CpRequest struct {
2030
// Direction: "to" copies from client to guest, "from" copies from guest to client
@@ -155,8 +165,12 @@ func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) {
155165
"subject", subject,
156166
"duration_ms", duration.Milliseconds(),
157167
)
158-
errMsg, _ := json.Marshal(CpError{Type: "error", Message: cpErr.Error()})
159-
ws.WriteMessage(websocket.TextMessage, errMsg)
168+
// Only send error message if it hasn't already been sent to the client
169+
var sentErr *cpErrorSent
170+
if !errors.As(cpErr, &sentErr) {
171+
errMsg, _ := json.Marshal(CpError{Type: "error", Message: cpErr.Error()})
172+
ws.WriteMessage(websocket.TextMessage, errMsg)
173+
}
160174
return
161175
}
162176

@@ -205,6 +219,7 @@ func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst
205219
}
206220

207221
// Read data chunks from WebSocket and forward to guest
222+
var receivedEndMessage bool
208223
for {
209224
msgType, data, err := ws.ReadMessage()
210225
if err != nil {
@@ -219,6 +234,7 @@ func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst
219234
var msg map[string]interface{}
220235
if json.Unmarshal(data, &msg) == nil {
221236
if msg["type"] == "end" {
237+
receivedEndMessage = true
222238
break
223239
}
224240
}
@@ -232,6 +248,11 @@ func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst
232248
}
233249
}
234250

251+
// If the WebSocket closed without receiving an end message, the transfer is incomplete
252+
if !receivedEndMessage {
253+
return fmt.Errorf("client disconnected before completing transfer")
254+
}
255+
235256
// Send end message to guest
236257
if err := stream.Send(&guest.CopyToGuestRequest{
237258
Request: &guest.CopyToGuestRequest_End{End: &guest.CopyToGuestEnd{}},
@@ -255,8 +276,10 @@ func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst
255276
resultJSON, _ := json.Marshal(result)
256277
ws.WriteMessage(websocket.TextMessage, resultJSON)
257278

258-
// Don't return an error here - the result message already contains any error info.
259-
// Returning an error would cause the caller to send a duplicate error message.
279+
if !resp.Success {
280+
// Return a wrapped error so the caller logs it correctly but doesn't send a duplicate
281+
return &cpErrorSent{err: fmt.Errorf("copy to guest failed: %s", resp.Error)}
282+
}
260283
return nil
261284
}
262285

@@ -334,9 +357,8 @@ func (s *ApiService) handleCopyFrom(ctx context.Context, ws *websocket.Conn, ins
334357
}
335358
errJSON, _ := json.Marshal(cpErr)
336359
ws.WriteMessage(websocket.TextMessage, errJSON)
337-
// Don't return an error here - the error message was already sent to the client.
338-
// Returning an error would cause the caller to send a duplicate error message.
339-
return nil
360+
// Return a wrapped error so the caller logs it correctly but doesn't send a duplicate
361+
return &cpErrorSent{err: fmt.Errorf("copy from guest failed: %s", r.Error.Message)}
340362
}
341363
}
342364

lib/guest/client.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,8 +514,16 @@ func CopyFromInstance(ctx context.Context, vsockSocketPath string, opts CopyFrom
514514
// Resolve the link target relative to the symlink's parent directory
515515
linkDir := filepath.Dir(targetPath)
516516
resolvedTarget := filepath.Clean(filepath.Join(linkDir, r.Header.LinkTarget))
517-
if !strings.HasPrefix(resolvedTarget, filepath.Clean(opts.DstPath)+string(filepath.Separator)) &&
518-
resolvedTarget != filepath.Clean(opts.DstPath) {
517+
cleanDst := filepath.Clean(opts.DstPath)
518+
// Check path containment - handle root destination specially
519+
var contained bool
520+
if cleanDst == "/" {
521+
// For root destination, any absolute path that doesn't contain ".." after cleaning is valid
522+
contained = !strings.Contains(resolvedTarget, "..")
523+
} else {
524+
contained = strings.HasPrefix(resolvedTarget, cleanDst+string(filepath.Separator)) || resolvedTarget == cleanDst
525+
}
526+
if !contained {
519527
return fmt.Errorf("invalid symlink target (escapes destination): %s", r.Header.LinkTarget)
520528
}
521529

0 commit comments

Comments
 (0)