Skip to content

Commit d087dec

Browse files
Merge pull request #118 from conorbronsdon/feat/remote-image-55
Accept image_url in upload_image with connection-time SSRF checks (#55)
2 parents 03be0ad + b23ce41 commit d087dec

10 files changed

Lines changed: 653 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ and the git tag history (`v0.1.0`–`v0.5.0`).
1515
Substack editor. Repeated, nested, formatted and multi-paragraph forms keep their Markdown with
1616
diagnostics. Draft export maps footnotes back and reports any layout that would not round-trip.
1717
Notes still reject footnotes.
18+
- `upload_image` accepts `image_url` (#55). The server downloads a PNG, JPEG, GIF, WebP or AVIF image
19+
over HTTPS, without Substack cookies, and uploads it through the existing path. Destinations are
20+
checked at connection time and after every redirect, so loopback, private, link-local, metadata and
21+
reserved addresses (including IPv4-mapped and tunnelled IPv6 forms) are refused and DNS rebinding
22+
is covered. Downloads are limited to 3 redirects, 5 MB and 15 seconds, and the bytes must match the
23+
declared type. Failures return a typed `code` with `upload_attempts: 0`. The Cloudflare Worker returns
24+
`remote_image_unavailable`. See [docs/remote-images.md](docs/remote-images.md).
1825

1926
## [1.1.1] - 2026-09-14
2027

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ Both tools require `publication` when multiple publications are configured.
266266
|------|-------------|
267267
| `create_draft` | Create a new draft from markdown (private) |
268268
| `update_draft` | Apply a reviewed change receipt; recheck unpublished state and report readback outcomes |
269-
| `upload_image` | Upload an image to Substack's CDN — returns a publicly-fetchable (unlisted) URL |
269+
| `upload_image` | Upload an image to Substack's CDN from a file, data URI or [public HTTPS URL](docs/remote-images.md) — returns a publicly-fetchable (unlisted) URL |
270270

271271
### Review before changing a draft
272272

docs/remote-images.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Remote image URLs
2+
3+
`upload_image` accepts exactly one of `image_path`, `image_base64` or
4+
`image_url`. With `image_url`, the server downloads the image and then uploads
5+
it through the same Substack endpoint as the other two. The result is the same:
6+
a hosted image URL that anyone with the link can fetch.
7+
8+
```json
9+
{ "image_url": "https://images.example.com/chart.png" }
10+
```
11+
12+
## What is refused
13+
14+
The download is a plain request made by the server, separate from the Substack
15+
client. It sends no Substack cookies, session token, `Authorization` or
16+
`Referer` header.
17+
18+
| Rule | Error `code` |
19+
| --- | --- |
20+
| Not an absolute `https` URL, contains credentials, or uses a non-default port | `invalid_url` |
21+
| Resolves to or names a loopback, private, carrier-grade NAT, link-local (including `169.254.169.254`), multicast, documentation or other reserved address | `blocked_destination` |
22+
| Host does not resolve | `dns_failed` |
23+
| More than 3 redirects, or a redirect without `Location` | `too_many_redirects`, `http_status` |
24+
| Any status other than 200 | `http_status` |
25+
| Compressed or otherwise encoded body | `unsupported_encoding` |
26+
| Declared or streamed size above 5 MB | `too_large` |
27+
| Whole download, including redirects, takes longer than 15 seconds | `timeout` |
28+
| Bytes are not PNG, JPEG, GIF, WebP or AVIF (SVG and HEIC included) | `unsupported_type` |
29+
| `Content-Type` missing or different from the detected format | `type_mismatch` |
30+
| Connection failed or ended early | `network` |
31+
32+
A refused download returns `isError: true` with
33+
`{ "code", "message", "upload_attempts": 0 }`, and nothing is uploaded.
34+
35+
## Address checks
36+
37+
IPv4 addresses must be outside the special-purpose ranges listed above. IPv6
38+
addresses must be global unicast (`2000::/3`). The server also refuses 6to4,
39+
Teredo, NAT64 and documentation prefixes, which can embed IPv4 destinations.
40+
IPv4-mapped forms such as `::ffff:169.254.169.254`, unique-local addresses such
41+
as `fd00:ec2::254`, and addresses with a zone ID are refused.
42+
43+
The check happens when each connection is made, including after every
44+
redirect. The server does not look the host up in advance and connect later, so
45+
DNS rebinding can't swap in a private address between check and connection. If
46+
a host resolves to several addresses and any one is refused, the whole request
47+
is refused. Literal IP addresses in a URL or `Location` header are checked
48+
before connecting.
49+
50+
Proxy environment variables are not used for this download.
51+
52+
## Deployments
53+
54+
The stdio and self-hosted HTTP servers include remote downloads. The Cloudflare
55+
Worker shares the tool list but not the Node network stack these checks depend
56+
on. There, `image_url` returns `remote_image_unavailable`; use `image_path` or
57+
`image_base64` instead.
58+
59+
Local file and data URI uploads are unchanged: `image_path` still infers the
60+
type from the file extension. The byte-signature and address checks above apply
61+
only to `image_url`.

src/__tests__/__snapshots__/output-contracts.test.ts.snap

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2808,11 +2808,16 @@ exports[`stable output contracts > classifies any public schema drift as potenti
28082808
"additionalProperties": false,
28092809
"properties": {
28102810
"image_base64": {
2811-
"description": "Base64-encoded image with data URI prefix (e.g., "data:image/png;base64,..."). Mutually exclusive with image_path.",
2811+
"description": "Base64-encoded image with data URI prefix (e.g., "data:image/png;base64,..."). Mutually exclusive with image_path and image_url.",
28122812
"type": "string",
28132813
},
28142814
"image_path": {
2815-
"description": "Absolute path to a local image file (e.g., "/Users/me/pic.png"). Read and encoded automatically; MIME type inferred from the extension. Mutually exclusive with image_base64.",
2815+
"description": "Absolute path to a local image file (e.g., "/Users/me/pic.png"). Read and encoded automatically; MIME type inferred from the extension. Mutually exclusive with image_base64 and image_url.",
2816+
"type": "string",
2817+
},
2818+
"image_url": {
2819+
"description": "HTTPS URL of a PNG, JPEG, GIF, WebP or AVIF image to download and upload. Sent without Substack cookies; private, loopback, link-local, metadata and reserved destinations are refused at connection time and on every redirect (at most 3). Limits: 5 MB and 15 seconds; the bytes must match the declared type. Not available on every deployment. Mutually exclusive with image_base64 and image_path.",
2820+
"maxLength": 2048,
28162821
"type": "string",
28172822
},
28182823
"publication": {
@@ -5427,11 +5432,16 @@ exports[`stable output contracts > classifies any public schema drift as potenti
54275432
"additionalProperties": false,
54285433
"properties": {
54295434
"image_base64": {
5430-
"description": "Base64-encoded image with data URI prefix (e.g., "data:image/png;base64,..."). Mutually exclusive with image_path.",
5435+
"description": "Base64-encoded image with data URI prefix (e.g., "data:image/png;base64,..."). Mutually exclusive with image_path and image_url.",
54315436
"type": "string",
54325437
},
54335438
"image_path": {
5434-
"description": "Absolute path to a local image file (e.g., "/Users/me/pic.png"). Read and encoded automatically; MIME type inferred from the extension. Mutually exclusive with image_base64.",
5439+
"description": "Absolute path to a local image file (e.g., "/Users/me/pic.png"). Read and encoded automatically; MIME type inferred from the extension. Mutually exclusive with image_base64 and image_url.",
5440+
"type": "string",
5441+
},
5442+
"image_url": {
5443+
"description": "HTTPS URL of a PNG, JPEG, GIF, WebP or AVIF image to download and upload. Sent without Substack cookies; private, loopback, link-local, metadata and reserved destinations are refused at connection time and on every redirect (at most 3). Limits: 5 MB and 15 seconds; the bytes must match the declared type. Not available on every deployment. Mutually exclusive with image_base64 and image_path.",
5444+
"maxLength": 2048,
54355445
"type": "string",
54365446
},
54375447
},

0 commit comments

Comments
 (0)