Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion images/chromium-headful/supervisor/services/chromium.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
command=/usr/local/bin/chromium-launcher
autostart=false
autorestart=true
startsecs=5
startsecs=0
stopsignal=KILL
stopwaitsecs=0
stdout_logfile=/var/log/supervisord/chromium
redirect_stderr=true
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
command=/usr/local/bin/chromium-launcher --headless
autostart=false
autorestart=true
startsecs=5
startsecs=0
stopsignal=KILL
stopwaitsecs=0
stdout_logfile=/var/log/supervisord/chromium
redirect_stderr=true
142 changes: 142 additions & 0 deletions server/cmd/api/api/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/onkernel/kernel-images/server/lib/logger"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/ziputil"
"github.com/onkernel/kernel-images/server/lib/zstdutil"
)

// fsWatch represents an in-memory directory watch.
Expand Down Expand Up @@ -850,3 +851,144 @@ func (s *ApiService) DownloadDirZip(ctx context.Context, request oapi.DownloadDi
body := io.NopCloser(bytes.NewReader(zipBytes))
return oapi.DownloadDirZip200ApplicationzipResponse{Body: body, ContentLength: int64(len(zipBytes))}, nil
}

func (s *ApiService) DownloadDirZstd(ctx context.Context, request oapi.DownloadDirZstdRequestObject) (oapi.DownloadDirZstdResponseObject, error) {
log := logger.FromContext(ctx)
path := request.Params.Path
if path == "" {
return oapi.DownloadDirZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "path cannot be empty"}}, nil
}

info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return oapi.DownloadDirZstd404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "directory not found"}}, nil
}
log.Error("failed to stat path", "err", err, "path", path)
return oapi.DownloadDirZstd500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to stat path"}}, nil
}
if !info.IsDir() {
return oapi.DownloadDirZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "path is not a directory"}}, nil
}

// Determine compression level
level := zstdutil.LevelDefault
if request.Params.CompressionLevel != nil {
switch *request.Params.CompressionLevel {
case oapi.Fastest:
level = zstdutil.LevelFastest
case oapi.Better:
level = zstdutil.LevelBetter
case oapi.Best:
level = zstdutil.LevelBest
default:
level = zstdutil.LevelDefault
}
}

// Create streaming response using a pipe
pr, pw := io.Pipe()

go func() {
defer pw.Close()
if err := zstdutil.TarZstdDir(pw, path, level); err != nil {
log.Error("failed to create tar.zst archive", "err", err, "path", path)
pw.CloseWithError(err)
}
}()

return oapi.DownloadDirZstd200ApplicationzstdResponse{Body: pr, ContentLength: 0}, nil
}

func (s *ApiService) UploadZstd(ctx context.Context, request oapi.UploadZstdRequestObject) (oapi.UploadZstdResponseObject, error) {
log := logger.FromContext(ctx)

if request.Body == nil {
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil
}

// Create temp file for uploaded archive
tmpArchive, err := os.CreateTemp("", "upload-*.tar.zst")
if err != nil {
log.Error("failed to create temporary file", "err", err)
return oapi.UploadZstd500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil
}
defer os.Remove(tmpArchive.Name())
defer tmpArchive.Close()

var destPath string
var stripComponents int
var archiveReceived bool

for {
part, err := request.Body.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Error("failed to read form part", "err", err)
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read form part"}}, nil
}

switch part.FormName() {
case "archive":
archiveReceived = true
if _, err := io.Copy(tmpArchive, part); err != nil {
log.Error("failed to read archive data", "err", err)
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read archive"}}, nil
}
case "dest_path":
data, err := io.ReadAll(part)
if err != nil {
log.Error("failed to read dest_path", "err", err)
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read dest_path"}}, nil
}
destPath = strings.TrimSpace(string(data))
if destPath == "" || !filepath.IsAbs(destPath) {
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "dest_path must be an absolute path"}}, nil
}
case "strip_components":
data, err := io.ReadAll(part)
if err != nil {
log.Error("failed to read strip_components", "err", err)
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read strip_components"}}, nil
}
if v, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil && v >= 0 {
stripComponents = v
}
default:
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid form field: " + part.FormName()}}, nil
}
}

// Validate required parts
if !archiveReceived || destPath == "" {
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "archive and dest_path are required"}}, nil
}

// Close temp writer and reopen for reading
if err := tmpArchive.Close(); err != nil {
log.Error("failed to finalize temporary archive", "err", err)
return oapi.UploadZstd500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil
}

// Open for reading
archiveReader, err := os.Open(tmpArchive.Name())
if err != nil {
log.Error("failed to reopen temporary archive", "err", err)
return oapi.UploadZstd500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil
}
defer archiveReader.Close()

// Extract the archive
if err := zstdutil.UntarZstd(archiveReader, destPath, stripComponents); err != nil {
msg := err.Error()
if strings.Contains(msg, "illegal file path") {
return oapi.UploadZstd400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid archive: path traversal detected"}}, nil
}
log.Error("failed to extract tar.zst archive", "err", err)
return oapi.UploadZstd500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to extract archive"}}, nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symlink/hardlink path traversal errors return 500 instead of 400

Low Severity

The UploadZstd error handling only checks for "illegal file path" to return a 400 response, but UntarZstd can also return security errors with different text: "illegal symlink target (absolute path)", "illegal symlink target (escapes destination)", and "illegal hard link target". These path traversal attempts are correctly blocked, but incorrectly return HTTP 500 with message "failed to extract archive" instead of HTTP 400 with "invalid archive: path traversal detected". This miscategorizes client errors as server errors.

Fix in Cursor Fix in Web

}

return oapi.UploadZstd201Response{}, nil
}
54 changes: 54 additions & 0 deletions server/cmd/chromium-launcher/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package main

import (
"context"
"flag"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"

"github.com/onkernel/kernel-images/server/lib/chromiumflags"
)
Expand All @@ -18,11 +21,21 @@ func main() {
runtimeFlagsPath := flag.String("runtime-flags", "/chromium/flags", "Path to runtime flags overlay file")
flag.Parse()

// Clean up stale lock file from previous SIGKILL termination
// Chromium creates this lock and doesn't clean it up when killed
_ = os.Remove("/home/kernel/user-data/SingletonLock")
_ = os.Remove("/home/kernel/user-data/SingletonSocket")
_ = os.Remove("/home/kernel/user-data/SingletonCookie")

// Inputs
internalPort := strings.TrimSpace(os.Getenv("INTERNAL_PORT"))
if internalPort == "" {
internalPort = "9223"
}

// Wait for devtools port to be available (handles SIGKILL socket cleanup delay)
waitForPort(internalPort, 5*time.Second)

baseFlags := os.Getenv("CHROMIUM_FLAGS")
runtimeTokens, err := chromiumflags.ReadOptionalFlagFile(*runtimeFlagsPath)
if err != nil {
Expand Down Expand Up @@ -104,3 +117,44 @@ func execLookPath(file string) (string, error) {
}
return exec.LookPath(file)
}

// waitForPort waits until the given port is available for binding on both IPv4 and IPv6.
// This handles the delay after SIGKILL before the kernel releases the socket.
// We disable SO_REUSEADDR to get an accurate check matching chromium's bind behavior.
func waitForPort(port string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
addrs := []string{"127.0.0.1:" + port, "[::1]:" + port}

// ListenConfig with Control to disable SO_REUSEADDR for accurate port availability check
lc := &net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var sockErr error
err := c.Control(func(fd uintptr) {
// Disable SO_REUSEADDR to match chromium's behavior
sockErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 0)
})
if err != nil {
return err
}
return sockErr
},
}

ctx := context.Background()
for time.Now().Before(deadline) {
allFree := true
for _, addr := range addrs {
ln, err := lc.Listen(ctx, "tcp", addr)
if err != nil {
allFree = false
break
}
ln.Close()
}
if allFree {
return
}
time.Sleep(50 * time.Millisecond)
}
// Timeout reached, proceed anyway and let chromium report the error
}
Loading
Loading