GitHub Security Advisory: GHSA-3fvx-xrxq-8jvv
Package: github.com/charmbracelet/soft-serve (GO)
Severity: CRITICAL (CVSS 9.1)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: >= 0.6.0, < 0.11.4
Patched in: 0.11.4

Summary: soft-serve vulnerable to SSRF via unvalidated LFS endpoint in repo import

Details:
While auditing the codebase in the wake of the webhook SSRF fix shipped in v0.11.1 (GHSA-vwq2-jx9q-9h9f), it was identified that the LFS import path was never given the same treatment. The webhook fix introduced dual-layer SSRF protection — ValidateWebhookURL() at creation time and secureHTTPClient with IP validation at dial time — but the LFS HTTP client still uses http.DefaultClient with no filtering at all.

### Summary

An authenticated SSH user can force the server to make HTTP requests to internal/private IP addresses by running `repo import` with a crafted `--lfs-endpoint` URL. The initial batch request is blind (the response from a metadata endpoint won't parse as valid LFS JSON), but an attacker hosting a fake LFS server can chain this into full read access to internal services by returning download URLs that point at internal targets.

### Details

The user-controlled endpoint flows through four files with zero validation:

**1. User supplies the URL via `--lfs-endpoint`** (`pkg/ssh/cmd/import.go:20-41`)

```go
cmd.Flags().StringVarP(&lfsEndpoint, "lfs-endpoint", "", "", "set the Git LFS endpoint")
```

The flag value is passed directly into `proto.RepositoryOptions{LFSEndpoint: lfsEndpoint}` at line 40 and then to `be.ImportRepository()`.

**2. Access check passes for any authenticated user** (`pkg/ssh/cmd/cmd.go:172-187`, `pkg/backend/user.go:94-100`)

The import command uses `checkIfCollab` as its `PersistentPreRunE`. For a new repo name (which is normal during import -- you're creating it), `AccessLevelForUser` hits this path:

```go
// pkg/backend/user.go:94-100
if user != nil {
    // If the repository doesn't exist, the user has read/write access.
    if anon > access.ReadWriteAccess {
        return anon
    }

    return access.ReadWriteAccess
}
```

This is by design -- any authenticated user can create repos via import or push (same model as Gitea/Gogs). The point isn't that the access control is wrong, just that any valid SSH key is enough to trigger the SSRF.

**3. Endpoint flows to the LFS client unvalidated** (`pkg/backend/repo.go:170-194`)

```go
// pkg/backend/repo.go:170-173
endpoint := remote
if opts.LFSEndpoint != "" {
    endpoint = opts.LFSEndpoint
}
```

When `opts.LFSEndpoint` is non-empty, it overrides the remote URL entirely. No URL validation, no IP check. It then flows through:

```go
// pkg/backend/repo.go:182-194
ep, err := lfs.NewEndpoint(endpoint)
// ...
client := lfs.NewClient(ep)
// ...
if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
```

`lfs.NewEndpoint` does URL parsing only -- no SSRF validation. `lfs.NewClient` calls `newHTTPClient`.

**4. HTTP client has no protection** (`pkg/lfs/http_client.go:24-31`)

```go
// pkg/lfs/http_client.go:24-31
func newHTTPClient(endpoint Endpoint) *httpClient {
    return &httpClient{
        client:   http.DefaultClient,
        endpoint: endpoint,
        transfers: map[string]TransferAdapter{
            TransferBasic: &BasicTrans