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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (`<link>`, `@import`) and `<img>` images are the only places
`Export` opens a connection. There is no script engine, no `@font-face` fetch,
and an `<iframe>`, `<object>` or `<embed>` is never loaded; CSS `background`
images are not painted. By default the engine's own client is used: it
follows redirects (Go's limit of 10 hops, to any host — a loopback or private
address included), times out after 30 s, and has no allow-list. A server that
renders HTML built from untrusted text should not rely on escaping alone to
keep that out of its own network:

```go
var refused atomic.Int64
doc, err := html2pdf.Export(src, html2pdf.Options{
Fetch: html2pdf.Offline, // no request is ever sent
OnRefused: func(string) { refused.Add(1) }, // optional; may run concurrently
})
```

`Options.Fetch` replaces the client for every http(s) sub-resource (it is the
engine's `Engine.Fetcher` hook); `html2pdf.Offline` refuses all of them with
`ErrOffline`. A refused resource is treated as missing — the layout continues,
`Export` does not fail — and `OnRefused` is called with its URL (it counts only
errors wrapping `ErrOffline`). Nothing is sent, so no redirect can be followed
and no DNS lookup happens. `data:` URLs are decoded in place and still render.
`file:` URLs are never read, with or without `Offline`, whatever `BaseURL` is
(the engine's `FileRoot` stays empty). From the shell: `--offline` (only with
`--in`; refusals are listed on stderr).

List markers are real text in the PDF: the engine (v0.18.0 and later) lays
out each list item's `::marker` with its text — "•", "3.", "iv." — and
html2pdf draws it where the layout put it, so a marker is selectable and
Expand Down
14 changes: 12 additions & 2 deletions cmd/html2pdf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func RootCmd() *cobra.Command {
in, url, base, out, media string
title, author, subject, keywords string
marginMm, imageDPI float64
offline bool
timeout time.Duration
)
cmd := &cobra.Command{
Expand All @@ -103,6 +104,9 @@ required.`,
if (in == "") == (url == "") {
return &usageError{"exactly one of --in or --url is required"}
}
if offline && url != "" {
return &usageError{"--offline cannot be combined with --url (the page itself is a fetch)"}
}
var src string
baseURL := base
if in != "" {
Expand All @@ -121,10 +125,15 @@ required.`,
src, baseURL = page.HTML, page.URL
}

doc, err := html2pdf.Export(src, html2pdf.Options{
opts := html2pdf.Options{
MarginMm: marginMm, BaseURL: baseURL, Media: media, ImageDPI: imageDPI,
Title: title, Author: author, Subject: subject, Keywords: keywords,
})
}
if offline {
opts.Fetch = html2pdf.Offline
opts.OnRefused = func(u string) { fmt.Fprintf(cmd.ErrOrStderr(), "html2pdf: offline: refused %s\n", u) }
}
doc, err := html2pdf.Export(src, opts)
if err != nil {
return err
}
Expand All @@ -151,6 +160,7 @@ required.`,
f.Float64VarP(&marginMm, "margin", "m", 20, "page margin, in millimetres, on all four sides")
f.StringVar(&media, "media", "print", `CSS medium the page is styled for: "print" (the page's @media print rules apply, its screen-only ones do not) or "screen"`)
f.Float64Var(&imageDPI, "image-dpi", 0, "cap on embedded bitmap density at painted size (0 = keep every fetched pixel; 150 is a print value)")
f.BoolVar(&offline, "offline", false, "make no network request at all: stylesheets and images that are not data: URLs are left out (only with --in)")
f.DurationVar(&timeout, "timeout", 45*time.Second, "fetch timeout for --url")
f.StringVar(&title, "title", "", "PDF Title (default: the document's <title>)")
f.StringVar(&author, "author", "", "PDF Author")
Expand Down
38 changes: 38 additions & 0 deletions cmd/html2pdf/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"

"github.com/go-pdfkit/reader"
Expand Down Expand Up @@ -97,6 +99,42 @@ func TestRejectsBothInAndURL(t *testing.T) {
}
}

// --offline sends nothing: a loopback server records zero hits for a page
// that links a stylesheet and an image, and the refusals are named on stderr.
// Without the flag (the control) the same page reaches the server.
func TestOfflineFlagMakesNoRequest(t *testing.T) {
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.NotFound(w, r)
}))
defer srv.Close()
dir, in := writeHTML(t, `<html><head><link rel=stylesheet href="`+srv.URL+`/a.css"></head><body><p>x</p><img src="`+srv.URL+`/a.png"></body></html>`)
out := filepath.Join(dir, "out.pdf")

if err, msg := exec(t, "--in", in, "--out", out); err != nil {
t.Fatalf("control: %v %s", err, msg)
}
if hits.Load() == 0 {
t.Fatal("control: the default run made no request; the test cannot see a fetch")
}
hits.Store(0)
err, msg := exec(t, "--in", in, "--out", out, "--offline")
if err != nil {
t.Fatalf("--offline: %v %s", err, msg)
}
isPDF(t, out)
if n := hits.Load(); n != 0 {
t.Errorf("--offline: the server saw %d request(s)", n)
}
if !strings.Contains(msg, "refused "+srv.URL+"/a.css") {
t.Errorf("--offline: refusals not reported: %q", msg)
}
if err, _ := exec(t, "--url", srv.URL, "--offline"); !isUsage(err) {
t.Errorf("--offline with --url: err = %v, want usage error", err)
}
}

func TestBadFlagIsUsage(t *testing.T) {
err, _ := exec(t, "--nosuchflag")
if !isUsage(err) {
Expand Down
26 changes: 24 additions & 2 deletions html2pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@
// source re-encoded as JPEG when opaque, everything else as a flate bitmap
// with a soft mask for transparency — see images.go and Options.ImageDPI. A
// relative src resolves against Options.BaseURL; an image that fails to
// fetch or decode is simply left out. Stylesheets and images are the two
// places Export touches the network.
// fetch or decode is simply left out. Stylesheets (<link>, @import) and
// images (<img>, CSS url()) are the only places Export touches the network:
// there is no script engine, no @font-face, and an <iframe>, <object> or
// <embed> is never loaded. Options.Fetch replaces the network client, and
// Offline refuses every request — see offline.go.
//
// # Navigation
//
Expand Down Expand Up @@ -136,6 +139,21 @@ type Options struct {
// hidden; "screen" styles the page as displayed. Width features
// (min-width, max-width) are evaluated at ViewportPx under either.
Media string

// Fetch replaces the network client for every http(s) sub-resource
// (stylesheets, @import chains, images). Nil, the default, keeps the
// engine's own browser-like client: it follows redirects (net/http's
// default of up to 10 hops, to any host, including a loopback or private
// one), has a 30 s timeout per request, and honours no allow-list — so a
// document built from untrusted text can make the server running Export
// send GET requests to any address it can reach. A server that renders
// such documents should set Fetch: Offline refuses everything.
Fetch Fetcher

// OnRefused, when non-nil, is called with the URL of each request Fetch
// refused by returning an error that wraps ErrOffline (as Offline does).
// It may be called from several goroutines at once; count with an atomic.
OnRefused func(rawurl string)
}

func (o Options) resolved() Options {
Expand Down Expand Up @@ -173,6 +191,10 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) {
// everything below is laid out against.
ctx := context.Background()
eng := engine.New()
if f := opts.fetcher(); f != nil {
eng.Fetcher = f
}
eng.FileRoot = "" // never read file: URLs, whatever BaseURL says
webDoc := &engine.Document{URL: opts.BaseURL, Root: root, HTML: htmlSrc}
media := css.Media{Type: opts.Media, Width: math.Abs(opts.ViewportPx)}
sheets := eng.LoadStylesheets(ctx, webDoc, media)
Expand Down
57 changes: 57 additions & 0 deletions offline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) the go-pdfkit/html2pdf authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.

package html2pdf

import (
"context"
"errors"
)

// Fetcher fetches one http(s) sub-resource — a stylesheet, an @import target,
// an image — for Export, returning its body and Content-Type. It is the
// go-webengine engine's own Engine.Fetcher hook (engine.go in
// go-webengine/engine), so it sees every request Export would otherwise send
// itself, and none that the engine resolves without the network: a data: URL
// is decoded in place, and a file: URL is refused by the engine unless it
// is given a FileRoot, which Export never does.
//
// A Fetcher may be called from several goroutines at once (stylesheets and
// images are loaded concurrently).
type Fetcher func(ctx context.Context, rawurl string) (body []byte, contentType string, err error)

// ErrOffline is what Offline returns. A Fetcher that wraps it (errors.Is)
// is counted as a refusal by Options.OnRefused.
var ErrOffline = errors.New("html2pdf: network access refused (offline)")

// Offline is a Fetcher that refuses every request. Pass it as Options.Fetch to
// render with no network access at all: a stylesheet, an @import, an image or
// a CSS background that is not a data: URL is treated as missing — the layout
// carries on without it, Export does not fail — and Options.OnRefused is told
// which URL it was.
//
// Nothing is ever sent, so no redirect can be followed, and no DNS lookup is
// made. data: URLs never reach the Fetcher (the engine decodes them in
// place), so inline images still render. A file: URL is refused by the engine
// itself: html2pdf never sets the engine's FileRoot, so a BaseURL of
// file:///… cannot make a relative src read the filesystem.
func Offline(ctx context.Context, rawurl string) ([]byte, string, error) {
return nil, "", ErrOffline
}

// fetcher adapts Options.Fetch for the engine, reporting refusals. It returns
// nil when the caller set no Fetch, leaving the engine's own client in place.
func (o Options) fetcher() Fetcher {
if o.Fetch == nil {
return nil
}
f, on := o.Fetch, o.OnRefused
return func(ctx context.Context, u string) ([]byte, string, error) {
body, ct, err := f(ctx, u)
if err != nil && on != nil && errors.Is(err, ErrOffline) {
on(u)
}
return body, ct, err
}
}
Loading
Loading