GitHub Security Advisory: GHSA-g9xj-752q-xh63
Package: code.vikunja.io/api (GO)
Severity: MODERATE (CVSS 6.4)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: <= 2.2.0
Patched in: 2.2.1

Summary: Vikjuna Bypasses Webhook SSRF Protections During OpenID Connect Avatar Download 

Details:
## Summary

The `DownloadImage` function in `pkg/utils/avatar.go` uses a bare `http.Client{}` with no SSRF protection when downloading user avatar images from the OpenID Connect `picture` claim URL. An attacker who controls their OIDC profile picture URL can force the Vikunja server to make HTTP GET requests to arbitrary internal or cloud metadata endpoints. This bypasses the SSRF protections that are correctly applied to the webhook system.

## Details

When a user authenticates via OpenID Connect, Vikunja extracts the `picture` claim from the ID token or UserInfo endpoint and passes it to `syncUserAvatarFromOpenID`, which calls `utils.DownloadImage` with the attacker-controlled URL:

**Claim extraction** (`pkg/modules/auth/openid/openid.go:70-78`):
```go
type claims struct {
	Email              string                   `json:"email"`
	Name               string                   `json:"name"`
	PreferredUsername   string                   `json:"preferred_username"`
	Nickname           string                   `json:"nickname"`
	VikunjaGroups      []map[string]interface{} `json:"vikunja_groups"`
	Picture            string                   `json:"picture"`
	// ...
}
```

**Avatar sync trigger** (`pkg/modules/auth/openid/openid.go:348-352`):
```go
// Try sync avatar if available
err = syncUserAvatarFromOpenID(s, u, cl.Picture)
if err != nil {
	log.Errorf("Error syncing avatar for user %s: %v", u.Username, err)
}
```

**Vulnerable download** (`pkg/utils/avatar.go:94-115`):
```go
func DownloadImage(url string) ([]byte, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
	}

	resp, err := (&http.Client{}).Do(req)  // No SSRF protection
	// ...
	return io.ReadAll(resp.Body)  // No size limit
}
```

In contrast, the webhook system correctly applies SSRF protection (`pkg/models/webhooks.go:306-310`):
```go
if !config.WebhooksAllowNonRoutableIPs.GetBool() {
	guardian := ssrf.New(ssrf.WithAnyPort())
	transport.DialContext = (&net.Dialer{
		Control: guardian.Safe,
	}).DialContext
}
```

The avatar download path has none of this protection. There is no URL scheme validation, no IP address filtering, and no response body size limit.

## PoC

**Prerequisites:** A Vikunja instance with OpenID Connect configured (e.g., Keycloak, Authentik). Attacker has an account on the OIDC provider.

**Step 1:** Set up a listener to observe incoming requests:
```bash
# On attacker-controlled server or internal service
nc -lvp 8888
```

**Step 2:** In the OIDC provider (e.g., Keycloak admin), update the attacker's user profile picture URL to an internal address:
```
http://169.254.169.254/latest/meta-data/iam/security-credentials/
```
Or to probe internal services:
```
http://internal-service:8888/admin
```

**Step 3:** Log in to Vikunja via the OIDC provider. After the callbac