GitHub Security Advisory: GHSA-h6hf-9846-xwrq
Package: lemmy_api_common (RUST)
Severity: MODERATE (CVSS 6.5)
CWE: CWE-918: Server-Side Request Forgery (SSRF)
Vulnerable versions: < 0.19.18
Patched in: 0.19.18

Summary: Lemmy has SSRF and internal image disclosure in post link metadata via unvalidated og:image

Details:
### Summary
Lemmy fetches metadata for user-supplied post URLs and, under the default `StoreLinkPreviews` image mode, downloads the preview image through local pict-rs. While the top-level page URL is checked against internal IP ranges, the extracted `og:image` URL is not subject to the same restriction.

As a result, an authenticated low-privileged user can submit an attacker-controlled public page whose Open Graph image points to an internal image endpoint. Lemmy will fetch that internal image server-side and store a local thumbnail that can then be served back to users.

### Details
The metadata fetch logic applies an internal-address check only to the initial post URL. After HTML parsing, `extract_opengraph_data()` accepts absolute `og:image` values and returns them as-is. Later, `generate_post_link_metadata()` passes that second-hop image URL into `generate_pictrs_thumbnail()`, which instructs local pict-rs to fetch it through `image/download?url=...`.

This creates a two-stage source-to-sink chain where the first URL is constrained, but the security boundary is bypassed through an unvalidated secondary resource.

Core vulnerable code path:

```rust
// crates/api_common/src/request.rs
let metadata = match &post.url {
  Some(url) => fetch_link_metadata(url, &context, false).await.unwrap_or_default(),
  _ => Default::default(),
};
```

```rust
// crates/api_common/src/request.rs
let og_image = page
  .opengraph
  .images
  .first()
  .and_then(|ogo| url.join(&ogo.url).ok());
```

```rust
// crates/api_common/src/request.rs
let thumbnail_url = if let (true, Some(url)) = (allow_generate_thumbnail, image_url.clone()) {
  generate_pictrs_thumbnail(&url, &context).await.ok().map(Into::into).or(image_url)
} else {
  image_url.clone()
};
```

```rust
// crates/api_common/src/request.rs
let fetch_url = format!(
  "{}image/download?url={}&resize={}",
  pictrs_config.url,
  encode(image_url.as_str()),
  context.settings().pictrs_config()?.max_thumbnail_size
);
```

These snippets show that only the outer page URL is checked, while the extracted `og:image` value becomes a server-side fetch target without an equivalent internal-address guard.

### PoC
Prerequisites:

- The attacker has a valid low-privileged account.
- The instance uses the default link preview storage mode.
- The attacker can post a link to a community they can access.

Practical reproduction flow:

1. Host a public HTML page under attacker control.
2. Add an Open Graph image tag whose value points to an internal image URL reachable from the Lemmy host, such as `http://127.0.0.1:8081/internal.png`.
3. Create a Lemmy post whose `url` is the attacker-controlled page.
4. Observe Lemmy fetch the public page, extract `og:image`, and then fetch the internal image through pict-rs.
5. Observe the created post receive a local thumbnail URL, demonstrating that the internal image was retrieved and cached.

Complete PoC attacker page:

```html
<html><head>
<meta property="og:image" content="http://1