From 0d266cd5cc4210d74789b9c6177d8155a0cc7fe5 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:13:41 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20durcir=20le=20scaffold=20runtime=20et=20?= =?UTF-8?q?la=20s=C3=A9curit=C3=A9=20des=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/ci.yml | 25 ++++++++++++++++++ scaffold/scaffold.go | 55 ++++++++++++++++++++++++++------------- scaffold/scaffold_test.go | 3 +++ update/update.go | 34 +++++++++++++++++++++--- update/update_test.go | 39 +++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11be548 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +"on": + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run go test + run: go test ./... + + - name: Run go vet + run: go vet ./... diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go index 918fdc9..c91ac45 100644 --- a/scaffold/scaffold.go +++ b/scaffold/scaffold.go @@ -70,19 +70,24 @@ func Generate(options Options) (Result, error) { } files := []generatedFile{ - {Path: ".gitignore", Content: renderTemplate(gitignoreTemplate, normalized), Mode: 0o644}, - {Path: "go.mod", Content: renderTemplate(goModTemplate, normalized), Mode: 0o644}, - {Path: "README.md", Content: renderTemplate(readmeTemplate, normalized), Mode: 0o644}, - {Path: "install.sh", Content: renderTemplate(installTemplate, normalized), Mode: 0o755}, - {Path: "mcp.toml", Content: renderTemplate(manifestTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Content: renderTemplate(mainTemplate, normalized), Mode: 0o644}, - {Path: filepath.Join("internal", "app", "app.go"), Content: renderTemplate(appTemplate, normalized), Mode: 0o644}, + {Path: ".gitignore", Template: gitignoreTemplate, Mode: 0o644}, + {Path: "go.mod", Template: goModTemplate, Mode: 0o644}, + {Path: "README.md", Template: readmeTemplate, Mode: 0o644}, + {Path: "install.sh", Template: installTemplate, Mode: 0o755}, + {Path: "mcp.toml", Template: manifestTemplate, Mode: 0o644}, + {Path: filepath.Join("cmd", normalized.BinaryName, "main.go"), Template: mainTemplate, Mode: 0o644}, + {Path: filepath.Join("internal", "app", "app.go"), Template: appTemplate, Mode: 0o644}, } written := make([]string, 0, len(files)) for _, file := range files { + content, err := renderTemplate(file.Template, normalized) + if err != nil { + return Result{}, fmt.Errorf("render scaffold file %q: %w", file.Path, err) + } + fullPath := filepath.Join(normalized.TargetDir, file.Path) - if err := writeFile(fullPath, file.Content, file.Mode, normalized.Overwrite); err != nil { + if err := writeFile(fullPath, content, file.Mode, normalized.Overwrite); err != nil { return Result{}, err } written = append(written, file.Path) @@ -96,9 +101,9 @@ func Generate(options Options) (Result, error) { } type generatedFile struct { - Path string - Content string - Mode os.FileMode + Path string + Template string + Mode os.FileMode } func writeFile(path, content string, mode os.FileMode, overwrite bool) error { @@ -126,15 +131,18 @@ func writeFile(path, content string, mode os.FileMode, overwrite bool) error { return nil } -func renderTemplate(src string, data normalizedOptions) string { - tpl := template.Must(template.New("scaffold").Parse(src)) +func renderTemplate(src string, data normalizedOptions) (string, error) { + tpl, err := template.New("scaffold").Parse(src) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } var builder strings.Builder if err := tpl.Execute(&builder, data); err != nil { - panic(err) + return "", fmt.Errorf("execute template: %w", err) } - return builder.String() + return builder.String(), nil } func normalizeOptions(options Options) (normalizedOptions, error) { @@ -859,6 +867,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strings" "gitea.lclr.dev/AI/mcp-framework/bootstrap" @@ -895,9 +904,19 @@ func Run(ctx context.Context, args []string, version string) error { } func NewRuntime(version string) (Runtime, error) { - manifestFile, _, err := manifest.LoadDefault(".") + manifestStartDir := "." + if executablePath, err := os.Executable(); err == nil { + if dir := strings.TrimSpace(filepath.Dir(executablePath)); dir != "" { + manifestStartDir = dir + } + } + + manifestFile, _, err := manifest.LoadDefault(manifestStartDir) if err != nil { - return Runtime{}, err + if !errors.Is(err, os.ErrNotExist) { + return Runtime{}, err + } + manifestFile = manifest.File{} } bootstrapInfo := manifestFile.BootstrapInfo() @@ -1162,7 +1181,7 @@ repository = "{{.ReleaseRepository}}" base_url = "{{.ReleaseBaseURL}}" asset_name_template = "{binary}-{os}-{arch}{ext}" checksum_asset_name = "{asset}.sha256" -checksum_required = false +checksum_required = true token_header = "Authorization" token_prefix = "token" token_env_names = ["{{.ReleaseTokenEnv}}"] diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go index 6749caa..bf72730 100644 --- a/scaffold/scaffold_test.go +++ b/scaffold/scaffold_test.go @@ -70,6 +70,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { "update.Run", "manifest.LoadDefault", "bootstrap.Run", + "os.Executable()", + "errors.Is(err, os.ErrNotExist)", } { if !strings.Contains(string(appGo), snippet) { t.Fatalf("app.go missing snippet %q", snippet) @@ -86,6 +88,7 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) { for _, snippet := range []string{ "binary_name = \"my-mcp\"", "[update]", + "checksum_required = true", "[secret_store]", "[environment]", "[profiles]", diff --git a/update/update.go b/update/update.go index 7ba393a..6c5dbfb 100644 --- a/update/update.go +++ b/update/update.go @@ -20,6 +20,7 @@ import ( ) const defaultAssetNameTemplate = "{binary}-{os}-{arch}{ext}" +const defaultMaxDownloadBytes int64 = 200 * 1024 * 1024 type Options struct { Client *http.Client @@ -32,6 +33,7 @@ type Options struct { ReleaseSource ReleaseSource GOOS string GOARCH string + MaxDownloadBytes int64 ValidateDownloaded ValidateDownloadedFunc ReplaceExecutable ReplaceExecutableFunc } @@ -124,6 +126,9 @@ func Run(ctx context.Context, opts Options) error { if strings.TrimSpace(opts.GOARCH) == "" { opts.GOARCH = runtime.GOARCH } + if opts.MaxDownloadBytes <= 0 { + opts.MaxDownloadBytes = defaultMaxDownloadBytes + } source := normalizeSource(opts.ReleaseSource) auth := ResolveAuth(source.Token, source) @@ -162,7 +167,7 @@ func Run(ctx context.Context, opts Options) error { return err } - downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source) + downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source, opts.MaxDownloadBytes) if err != nil { return err } @@ -394,7 +399,18 @@ func (r Release) AssetURL(assetName, releaseURL string) (string, error) { ) } -func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source ReleaseSource) (string, error) { +func DownloadReleaseAsset( + ctx context.Context, + client *http.Client, + assetURL, targetPath string, + auth Auth, + source ReleaseSource, + maxDownloadBytes int64, +) (string, error) { + if maxDownloadBytes <= 0 { + maxDownloadBytes = defaultMaxDownloadBytes + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) if err != nil { return "", fmt.Errorf("build artifact download request: %w", err) @@ -419,6 +435,13 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta strings.TrimSpace(string(body)), ) } + if resp.ContentLength > 0 && resp.ContentLength > maxDownloadBytes { + return "", fmt.Errorf( + "download release artifact: content length %d exceeds limit %d bytes", + resp.ContentLength, + maxDownloadBytes, + ) + } existingInfo, err := os.Stat(targetPath) if err != nil { @@ -437,9 +460,14 @@ func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, ta return "", copyErr } - if _, err := io.Copy(tempFile, resp.Body); err != nil { + limited := &io.LimitedReader{R: resp.Body, N: maxDownloadBytes + 1} + written, err := io.Copy(tempFile, limited) + if err != nil { return cleanup(fmt.Errorf("write downloaded artifact: %w", err)) } + if written > maxDownloadBytes { + return cleanup(fmt.Errorf("write downloaded artifact: size exceeds limit %d bytes", maxDownloadBytes)) + } if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil { return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err)) } diff --git a/update/update_test.go b/update/update_test.go index ab427e3..9c3f98d 100644 --- a/update/update_test.go +++ b/update/update_test.go @@ -384,6 +384,45 @@ func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) return fn(req) } +func TestDownloadReleaseAssetRejectsArtifactOverLimit(t *testing.T) { + client := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://releases.example.com/artifact" { + t.Fatalf("unexpected url: %s", r.URL.String()) + } + body := "123456" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + ContentLength: int64(len(body)), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "graylog-mcp") + if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil { + t.Fatalf("WriteFile target: %v", err) + } + + _, err := DownloadReleaseAsset( + context.Background(), + client, + "https://releases.example.com/artifact", + target, + Auth{}, + ReleaseSource{}, + 5, + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "exceeds limit") { + t.Fatalf("error = %v", err) + } +} + func TestRunReplacesExecutableWithLatestArtifact(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("self-replace is not supported on windows")