Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/article.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ Only available fields are emitted. String values use JSON string quoting, which
is a safe YAML scalar. `length` is numeric. Markdown pass-through emits only
`url`.

Terminal output may use the normal formatter, color, and pager. Output files,
pipes, and clipboard destinations receive raw, uncolored Markdown.
Terminal output may use the normal formatter and color. On a terminal,
article images are fetched and rendered with the configured image policy. Use
`--image off` to disable this behavior. Article images are not fetched for
output files, pipes, or clipboard destinations, which receive raw, uncolored
Markdown. Image fetches are bounded and failed images fall back to their alt
text and URL.

Article mode cannot be combined with WebSockets, gRPC, DNS/TLS inspection,
`--discard`, `--remote-name`, or `--remote-header-name`.
4 changes: 3 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,9 @@ fetch --format on example.com # Force formatting
Extract a readable HTML/XHTML page as Markdown with YAML frontmatter. Markdown
responses (`text/markdown` and `text/x-markdown`) pass through after a `url`
frontmatter field. Article mode buffers at most 16 MiB of decoded content and
does not execute JavaScript. It cannot be combined with `--discard`,
does not execute JavaScript. On terminals, embedded article images are fetched
and rendered unless `--image off` is set. Output files and pipes retain image
links in the raw Markdown. It cannot be combined with `--discard`,
`--remote-name`, or `--remote-header-name`.

The frontmatter fields are `title`, `byline`, `site_name`, `published_time`,
Expand Down
4 changes: 3 additions & 1 deletion docs/image-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ Control how images are rendered:
| `off` | Disable image rendering |

`native` remains accepted as a compatibility alias for `auto`. External
programs never run in `auto` mode.
programs never run in `auto` mode. When `--article` formats a document on a
terminal, embedded images use this same policy. Images are not fetched for
files, pipes, or clipboard output.

```sh
fetch --image auto example.com/photo.jpg
Expand Down
7 changes: 4 additions & 3 deletions docs/limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ contain `key`, `token`, `secret`, `password`, `credential`, `signature`, or
visible. HAR artifacts are the exception because they are intended to reproduce
the exchange and can contain sensitive data.

Image rendering also rejects decoded dimensions above 8192 by 8192. External
image adapters and pagers run without a shell, with bounded output and a
process deadline. See [output formatting](output-formatting.md), [HAR](har.md),
Image rendering also rejects decoded dimensions above 8192 by 8192. Article
image fetching allows at most 16 images, 8 MiB per image, and 32 MiB in total.
External image adapters and pagers run without a shell, with bounded output and
a process deadline. See [output formatting](output-formatting.md), [HAR](har.md),
[updates](updates.md), and [Agent Skill](agent-skill.md) for feature details.
3 changes: 2 additions & 1 deletion docs/output-formatting.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ URL after redirects to resolve links.
Article mode decodes the response before extraction and accepts at most 16 MiB
of decoded content. It does not run JavaScript, so content rendered only by
client-side scripts is not available. Output files and pipes receive raw,
uncolored Markdown. On a terminal, `--format`, `--color`, and `--pager` affect
uncolored Markdown. On a terminal, embedded article images are fetched and
rendered unless `--image off` is set. `--format`, `--color`, and `--pager` affect
presentation only.

```sh
Expand Down
20 changes: 20 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,26 @@ func (c *Client) HTTPClient() *http.Client {
return c.c
}

// LookupIPAddr resolves host using the resolver configured for this client.
// It keeps policy checks consistent with the addresses used by its transport.
func (c *Client) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
if c != nil && c.resolver != nil {
return c.resolver.LookupIPAddr(ctx, host)
}
return net.DefaultResolver.LookupIPAddr(ctx, host)
}

// UsesProxy reports whether requests for target use a configured proxy.
// Callers that inspect the peer connection must account for the proxy because
// the peer is then the proxy, not the requested origin.
func (c *Client) UsesProxy(target *url.URL) bool {
if c == nil || target == nil {
return false
}
proxy, err := ProxyForURL(c.proxy, target)
return err == nil && proxy != nil
}

// ResolverProvenance identifies the resolver policy used by this client. It
// is safe for diagnostics because resolver endpoints redact credentials during
// parsing and display construction.
Expand Down
261 changes: 261 additions & 0 deletions internal/fetch/article_images.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package fetch

import (
"context"
"errors"
"io"
"net"
"net/http/httptrace"
"net/url"
"strings"
"time"

"github.com/ryanfowler/fetch/internal/client"
"github.com/ryanfowler/fetch/internal/core"
imageoutput "github.com/ryanfowler/fetch/internal/image"
)

const (
maxArticleImages = 16
maxArticleImageBytes = 8 << 20
maxArticleImageTotal = 32 << 20
articleImageTimeout = 15 * time.Second
)

// articleImageFetcher resolves and renders Markdown images for an article's
// terminal presentation. It deliberately does not modify the Markdown source.
type terminalPresentationReader struct {
io.Reader
}

func (terminalPresentationReader) terminalPresentation() {}

type articleImageFetcher struct {
ctx context.Context
client *client.Client
mode core.ImageSetting
baseURL *url.URL

images map[string][]byte
failed map[string]bool
count int
total int64
rendered int
}

func newArticleImageFetcher(ctx context.Context, c *client.Client, mode core.ImageSetting, pageURL string) *articleImageFetcher {
baseURL, err := url.Parse(pageURL)
if err != nil || baseURL == nil {
return nil
}
return &articleImageFetcher{
ctx: ctx,
client: c,
mode: mode,
baseURL: baseURL,
images: make(map[string][]byte),
failed: make(map[string]bool),
}
}

func (f *articleImageFetcher) render(destination string, dst io.Writer) bool {
if f == nil || f.client == nil || dst == nil || f.rendered >= maxArticleImages {
return false
}
target, ok := f.resolve(destination)
if !ok {
return false
}
key := target.String()
data, cached := f.images[key]
if !cached {
if f.failed[key] || f.count >= maxArticleImages || f.total >= maxArticleImageTotal {
return false
}
f.count++
var err error
data, err = f.fetch(target)
if err != nil {
f.failed[key] = true
return false
}
if int64(len(data)) > maxArticleImageTotal-f.total {
f.failed[key] = true
return false
}
f.images[key] = data
f.total += int64(len(data))
}

if err := imageoutput.RenderWithModeTo(f.ctx, data, f.mode, dst); err != nil {
return false
}
f.rendered++
return true
}

func (f *articleImageFetcher) fetch(target *url.URL) ([]byte, error) {
ctx, cancel := context.WithTimeout(f.ctx, articleImageTimeout)
defer cancel()

req, err := f.client.NewRequest(ctx, client.RequestConfig{URL: target})
if err != nil {
return nil, err
}
// Do not forward the article request's custom headers or credentials to
// image hosts. The shared client still supplies the configured transport,
// proxy, TLS, compression, and cookie policy.
req.Header.Set("Accept", "image/*")
ctx = client.WithRedirectValidator(ctx, func(hop client.RedirectHop) error {
if hop.NextRequest == nil || !safeArticleImageURL(ctx, hop.NextRequest.URL, f.client.LookupIPAddr) {
return errors.New("article image redirect targets a private or non-public address")
}
return nil
})
req = req.WithContext(ctx)
blockedPeer := false
if !f.client.UsesProxy(req.URL) {
req = req.WithContext(httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
if articleImagePrivatePeer(info.Conn) {
blockedPeer = true
_ = info.Conn.Close()
}
},
}))
}
resp, err := f.client.Do(req)
if blockedPeer {
if resp != nil {
_ = resp.Body.Close()
}
return nil, errors.New("article image connection targets a private address")
}
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, io.ErrUnexpectedEOF
}
if resp.Request == nil || !safeArticleImageURL(ctx, resp.Request.URL, f.client.LookupIPAddr) {
return nil, io.ErrUnexpectedEOF
}
if resp.ContentLength > maxArticleImageBytes {
return nil, io.ErrShortBuffer
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArticleImageBytes+1))
if err != nil {
return nil, err
}
if len(data) > maxArticleImageBytes {
return nil, io.ErrShortBuffer
}
return data, nil
}

func (f *articleImageFetcher) resolve(destination string) (*url.URL, bool) {
destination = strings.TrimSpace(destination)
if destination == "" || f.baseURL == nil {
return nil, false
}
target, err := url.Parse(destination)
if err != nil {
return nil, false
}
target = f.baseURL.ResolveReference(target)
target.Fragment = ""
if !safeArticleImageURL(f.ctx, target, f.client.LookupIPAddr) {
return nil, false
}
return target, true
}

func safeArticleImageURL(ctx context.Context, target *url.URL, lookup func(context.Context, string) ([]net.IPAddr, error)) bool {
if target == nil || target.User != nil || target.Host == "" {
return false
}
switch strings.ToLower(target.Scheme) {
case "http", "https":
default:
return false
}

host := strings.ToLower(strings.TrimSuffix(target.Hostname(), "."))
if host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") {
return false
}
if ip := net.ParseIP(host); ip != nil {
return safeArticleImageIP(ip)
}
if ctx == nil {
ctx = context.Background()
}
if lookup == nil {
lookup = net.DefaultResolver.LookupIPAddr
}
addresses, err := lookup(ctx, host)
if err != nil || len(addresses) == 0 {
return false
}
for _, address := range addresses {
if !safeArticleImageIP(address.IP) {
return false
}
}
return true
}

func safeArticleImageIP(ip net.IP) bool {
if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() {
return false
}
if v4 := ip.To4(); v4 != nil {
return !nonPublicArticleIPv4(v4)
}
// Documentation, benchmarking, discard, and reserved IPv6 ranges are not
// routable article destinations and must not be used to bypass this policy.
return !(len(ip) == net.IPv6len &&
(ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x0d && ip[3] == 0xb8 ||
ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x02 ||
ip[0] == 0x3f && ip[1]&0xf0 == 0xf0 ||
ip[0] == 0x00 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 &&
ip[4] == 0x00 && ip[5] == 0x00 && ip[6] == 0x00 && ip[7] == 0x00))
}

func nonPublicArticleIPv4(ip net.IP) bool {
if len(ip) < net.IPv4len {
return true
}
switch {
case ip[0] == 0 || ip[0] == 127 || ip[0] >= 224:
return true
case ip[0] == 100 && ip[1] >= 64 && ip[1] <= 127:
return true // Shared address space, RFC 6598.
case ip[0] == 169 && ip[1] == 254:
return true
case ip[0] == 192 && (ip[1] == 0 || ip[1] == 2 || ip[1] == 88 || ip[1] == 168):
return true
case ip[0] == 198 && (ip[1] == 18 || ip[1] == 19 || ip[1] == 51):
return true
case ip[0] == 203 && ip[1] == 0 && ip[2] == 113:
return true
case ip[0] >= 240:
return true
default:
return false
}
}

func articleImagePrivatePeer(conn net.Conn) bool {
if conn == nil {
return false
}
host, _, err := net.SplitHostPort(conn.RemoteAddr().String())
if err != nil {
return false
}
ip := net.ParseIP(host)
return ip != nil && !safeArticleImageIP(ip)
}
47 changes: 47 additions & 0 deletions internal/fetch/article_images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package fetch

import (
"context"
"net"
"net/url"
"testing"
)

func TestSafeArticleImageURLRejectsNonPublicResolvedAddresses(t *testing.T) {
tests := []struct {
name string
ip string
}{
{name: "shared", ip: "100.100.100.200"},
{name: "metadata", ip: "169.254.169.254"},
{name: "documentation", ip: "192.0.2.10"},
{name: "ipv6 documentation", ip: "2001:db8::10"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
target, err := url.Parse("https://images.example/photo.jpg")
if err != nil {
t.Fatal(err)
}
lookup := func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP(test.ip)}}, nil
}
if safeArticleImageURL(context.Background(), target, lookup) {
t.Fatalf("safeArticleImageURL accepted %s", test.ip)
}
})
}
}

func TestSafeArticleImageURLAcceptsPublicResolvedAddress(t *testing.T) {
target, err := url.Parse("https://images.example/photo.jpg")
if err != nil {
t.Fatal(err)
}
lookup := func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("203.0.114.10")}}, nil
}
if !safeArticleImageURL(context.Background(), target, lookup) {
t.Fatal("safeArticleImageURL rejected a public address")
}
}
Loading
Loading