From bb1c561c2f7647b2dc86fb870bb538d0c22b19d2 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 1 Nov 2026 08:56:21 +0100 Subject: [PATCH] feat: Options.Fetch and html2pdf.Offline, render with no network access Export fetched stylesheets (, @import) and images () with the engine's default client: redirects followed to any host, no allow-list. A server rendering HTML built from untrusted text then depended on its HTML escaping alone to stay out of its own network (go-llmflow/gateway#123). Options.Fetch is the engine's Engine.Fetcher hook (engine.go:68-74), which every http(s) sub-resource goes through (engine.go:234-256). Offline refuses all of them with ErrOffline; a refused resource is treated as missing and Options.OnRefused is told its URL. data: URLs are decoded by the engine before the hook, file: URLs are refused by the engine (FileRoot is empty and Export now pins it so). CLI: --offline. Tests: a loopback server records hits for every kind of sub-resource (zero under Offline, a control that sees a hit without it), data: images still render, a file: BaseURL reads nothing, hostile URL table, fuzz target. Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++- cmd/html2pdf/main.go | 14 +- cmd/html2pdf/main_test.go | 38 ++++ html2pdf.go | 26 ++- offline.go | 57 ++++++ offline_test.go | 378 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 offline.go create mode 100644 offline_test.go diff --git a/README.md b/README.md index 915175e..11f20ee 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ go run ./cmd/html2pdf --in report.html --title "Q3 report" --author "A. Writer" The CLI is a [cobra](https://github.com/spf13/cobra) command with POSIX flags: `--in/-i`, `--url/-u`, `--out/-o`, `--base/-b`, `--margin/-m` (mm), -`--media`, `--image-dpi`, `--timeout`, and the PDF information entries +`--media`, `--image-dpi`, `--offline`, `--timeout`, and the PDF information entries `--title`, `--author`, `--subject`, `--keywords`. The single-dash spellings of earlier builds (`-in`, `-out`, `-margin` …) are still accepted. Exit status 2 is a usage error, 1 a runtime failure. @@ -162,6 +162,35 @@ pixel as Chrome and WeasyPrint do; WeasyPrint's `--dpi` is the same lever. A relative `src` resolves against `BaseURL`; an image that fails to fetch or decode is left out. +### Rendering with no network access + +Stylesheets (``, `@import`) and `` images are the only places +`Export` opens a connection. There is no script engine, no `@font-face` fetch, +and an ``, false}, + {"object", ``, false}, + {"embed", ``, false}, + {"script src", `

x

`, false}, + {"video poster", ``, false}, + {"favicon link", `

x

`, false}, + {"meta refresh", `

x

`, false}, + {"base href (ignored: BaseURL decides)", ``, true}, + {"form action and anchors", `
a`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + doc := "" + c.html + "" + + srv.reset() + opts, refused := offlineOpts(abs + "/") + exportBytes(t, doc, opts) + if n := srv.count(); n != 0 { + t.Fatalf("Offline: the server saw %d request(s): %v", n, srv.hits) + } + if c.loaded && refused.Load() == 0 { + t.Errorf("Offline: no refusal counted for a document that asks for a resource") + } + + srv.reset() + exportBytes(t, doc, Options{BaseURL: abs + "/"}) + got := srv.count() + if c.loaded && got == 0 { + t.Errorf("control: the default options made no request; the test cannot see a fetch") + } + if !c.loaded && got != 0 { + t.Logf("control: the engine now fetches this kind (%d hit(s): %v); move it to loaded", got, srv.hits) + t.Fail() + } + }) + } +} + +// The count of refusals equals the number of requests the default options +// would have sent for the same document. +func TestOfflineCountsRefusals(t *testing.T) { + srv := newRecorder(t) + doc := ` +
x
` + exportBytes(t, doc, Options{BaseURL: srv.URL + "/"}) + want := srv.count() + if want < 3 { + t.Fatalf("control: %d hits, want at least 3", want) + } + srv.reset() + var urls []string + var mu sync.Mutex + exportBytes(t, doc, Options{BaseURL: srv.URL + "/", Fetch: Offline, OnRefused: func(u string) { + mu.Lock() + urls = append(urls, u) + mu.Unlock() + }}) + if srv.count() != 0 { + t.Fatalf("Offline: %d hits", srv.count()) + } + // the stylesheet is refused, so its @import is never reached: 1 sheet + 3 images + // the default also fetched the sheet's @import, which Offline never reaches + if len(urls) != want-1 || want != 4 { + t.Errorf("refusals: %d %v (default hits %d), want 3 refusals (the sheet and two images) and 4 hits", len(urls), urls, want) + } + for _, u := range urls { + if !strings.HasPrefix(u, srv.URL) { + t.Errorf("refused URL %q does not name the server", u) + } + } +} + +// A data: image still renders under Offline, and is not counted as a refusal. +func TestOfflineKeepsDataImages(t *testing.T) { + doc := `` + opts, refused := offlineOpts("") + pdf := exportBytes(t, doc, opts) + if n := bytes.Count(pdf, []byte("/Subtype /Image")); n < 2 { + t.Errorf("images embedded: %d, want 2", n) + } + if n := refused.Load(); n != 0 { + t.Errorf("refusals for data: images: %d, want 0", n) + } + if want := exportBytes(t, doc, Options{}); !bytes.Equal(pdf, want) { + t.Error("Offline changed the output of a document with only data: images") + } +} + +// A missing resource is left out, never an error: the page still exports. +func TestOfflineRefusalIsNotAnError(t *testing.T) { + opts, _ := offlineOpts("https://example.invalid/") + doc, err := Export(`

kept text

`, opts) + if err != nil { + t.Fatalf("Export: %v", err) + } + var buf bytes.Buffer + if err := doc.Write(&buf); err != nil { + t.Fatal(err) + } + if bytes.Count(buf.Bytes(), []byte("/Subtype /Image")) != 0 { + t.Error("an image was embedded from nowhere") + } +} + +// A file: BaseURL does not turn a relative src into a filesystem read, with +// or without Offline: the engine's FileRoot stays empty. +func TestFileBaseURLReadsNothing(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.png"), raw(fxPNGAlpha), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "a.css"), []byte("p{display:none}"), 0o600); err != nil { + t.Fatal(err) + } + base := (&url.URL{Scheme: "file", Path: dir + "/"}).String() + doc := `

t

` + for name, opts := range map[string]Options{ + "default": {BaseURL: base}, + "offline": {BaseURL: base, Fetch: Offline}, + } { + pdf := exportBytes(t, doc, opts) + if n := bytes.Count(pdf, []byte("/Subtype /Image")); n != 0 { + t.Errorf("%s: a file: image was read (%d embedded)", name, n) + } + } +} + +// A custom Fetch is honoured and sees only http(s) URLs. +func TestFetchHookIsUsed(t *testing.T) { + var got []string + var mu sync.Mutex + opts := Options{BaseURL: "https://example.test/", Fetch: func(_ context.Context, u string) ([]byte, string, error) { + mu.Lock() + got = append(got, u) + mu.Unlock() + return raw(fxPNGAlpha), "image/png", nil + }} + pdf := exportBytes(t, ``, opts) + if len(got) != 1 || got[0] != "https://example.test/x.png" { + t.Errorf("Fetch saw %v, want only the one http(s) URL", got) + } + if n := bytes.Count(pdf, []byte("/Subtype /Image")); n != 2 { + t.Errorf("images embedded: %d, want 2", n) + } + // an error that is not ErrOffline is not a counted refusal + var n atomic.Int64 + opts.Fetch = func(context.Context, string) ([]byte, string, error) { return nil, "", errors.New("boom") } + opts.OnRefused = func(string) { n.Add(1) } + exportBytes(t, ``, opts) + if n.Load() != 0 { + t.Error("a plain error was counted as a refusal") + } + // a wrapped ErrOffline is + opts.Fetch = func(context.Context, string) ([]byte, string, error) { + return nil, "", errors.Join(errors.New("policy"), ErrOffline) + } + exportBytes(t, ``, opts) + if n.Load() != 1 { + t.Errorf("wrapped ErrOffline: %d refusals, want 1", n.Load()) + } +} + +func TestOfflineFetcherRefusesEverything(t *testing.T) { + for _, u := range []string{"http://127.0.0.1/", "https://x/", "data:text/plain,hi", "file:///etc/passwd", "", "ftp://x"} { + body, ct, err := Offline(context.Background(), u) + if body != nil || ct != "" || !errors.Is(err, ErrOffline) { + t.Errorf("Offline(%q) = %v, %q, %v", u, body, ct, err) + } + } +} + +// Hostile URL shapes, in every place that takes one: none reaches the server. +func TestOfflineHostileURLs(t *testing.T) { + srv := newRecorder(t) + u, _ := url.Parse(srv.URL) + hostport := u.Host + hostile := []string{ + srv.URL + "/i.png", + srv.URL + "/r", + "//" + hostport + "/i.png", + "HTTP://" + hostport + "/i.png", + " \t" + srv.URL + "/i.png", + "http://user:pw@" + hostport + "/i.png", + "http://" + hostport + "@" + hostport + "/i.png", + "http:" + hostport + "/i.png", + "http:/" + hostport + "/i.png", + "http:\\\\" + hostport + "\\i.png", + "http://" + strings.Replace(hostport, "127.0.0.1", "[::ffff:127.0.0.1]", 1) + "/i.png", + "http://localhost:" + u.Port() + "/i.png", + "http://2130706433:" + u.Port() + "/i.png", + "http://0x7f.1:" + u.Port() + "/i.png", + "http://" + hostport + "/%2e%2e/i.png", + "http://" + hostport + "/i.png#frag", + "http://" + hostport + "/i.png?" + strings.Repeat("a", 70000), + "../i.png", + "/i.png", + "i.png", + "?x", + "file:///etc/passwd", + "FILE:///etc/passwd", + "file://" + hostport + "/etc/passwd", + "javascript:alert(1)", + "ftp://" + hostport + "/i.png", + "gopher://" + hostport + "/", + "ws://" + hostport + "/", + "blob:" + srv.URL + "/x", + "data:image/png,not-a-png", + "data:text/css,@import url(" + srv.URL + "/s.css);", + "data:image/svg+xml,", + "", + "\x00" + srv.URL, + } + for i, h := range hostile { + doc := "
x
" + opts, _ := offlineOpts(srv.URL + "/") + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("case %d (%q): panic: %v", i, truncate(h), r) + } + }() + exportBytes(t, doc, opts) + }() + if n := srv.count(); n != 0 { + t.Errorf("case %d (%q): %d request(s): %v", i, truncate(h), n, srv.hits) + srv.reset() + } + } +} + +func truncate(s string) string { + if len(s) > 60 { + return s[:60] + "..." + } + return s +} + +// FuzzOfflineMakesNoRequest: whatever string sits in a URL position, under +// Offline the loopback server is never reached. +func FuzzOfflineMakesNoRequest(f *testing.F) { + srv := newRecorder(f) + for _, s := range []string{srv.URL + "/i.png", "/s.css", "//" + srv.Listener.Addr().String() + "/i.png", "i.png", "file:///etc/hosts", "data:,x", "http://[::1]/", "\x00", "http://", ""} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, ref string) { + if len(ref) > 4096 { + t.Skip() + } + doc := "
x
" + srv.reset() + opts, _ := offlineOpts(srv.URL + "/") + d, err := Export(doc, opts) + if err != nil { + return + } + var buf bytes.Buffer + _ = d.Write(&buf) + if n := srv.count(); n != 0 { + t.Fatalf("%d request(s) for %q: %v", n, ref, srv.hits) + } + }) +} + +// attr makes s safe inside a double-quoted attribute and a single-quoted CSS +// string, so the fuzzer explores URLs, not markup injection. +func attr(s string) string { + return strings.NewReplacer(`"`, "%22", "'", "%27", "\\", "%5C", "&", "%26", "<", "%3C", ")", "%29").Replace(s) +}