mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-17 17:53:17 +01:00
Compare commits
14 Commits
v8.35.0
...
7a730cfdc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a730cfdc2 | ||
|
|
b213f2ffed | ||
|
|
8d1eeaa73a | ||
|
|
2f6818d3df | ||
|
|
38f6e466d4 | ||
|
|
e39840d5cf | ||
|
|
9b48d3f84e | ||
|
|
05bde96334 | ||
|
|
8b2c15d5de | ||
|
|
8d327a5196 | ||
|
|
dc61c3631e | ||
|
|
dc7c68152c | ||
|
|
357c3b4a59 | ||
|
|
91f2587fef |
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ body:multipart-form {
|
|||||||
~width: 800
|
~width: 800
|
||||||
~height: 600
|
~height: 600
|
||||||
~clip: false
|
~clip: false
|
||||||
|
~selector:
|
||||||
~format: png
|
~format: png
|
||||||
~quality: 100
|
~quality: 100
|
||||||
~optimizeForSpeed: false
|
~optimizeForSpeed: false
|
||||||
|
|||||||
2
.github/workflows/continuous-integration.yml
vendored
2
.github/workflows/continuous-integration.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
- name: Run linters
|
- name: Run linters
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.10.1
|
version: v2.12.2
|
||||||
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
name: Lint non-Golang codebase
|
name: Lint non-Golang codebase
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -147,10 +147,12 @@ NO_CONCURRENCY=false
|
|||||||
# chromium-screenshot-html
|
# chromium-screenshot-html
|
||||||
# chromium-screenshot-markdown
|
# chromium-screenshot-markdown
|
||||||
# chromium-screenshot-url
|
# chromium-screenshot-url
|
||||||
|
# chromium-ssrf
|
||||||
# debug
|
# debug
|
||||||
# health
|
# health
|
||||||
# libreoffice
|
# libreoffice
|
||||||
# libreoffice-convert
|
# libreoffice-convert
|
||||||
|
# libreoffice-ssrf
|
||||||
# output-filename
|
# output-filename
|
||||||
# pdfengines
|
# pdfengines
|
||||||
# pdfengines-convert
|
# pdfengines-convert
|
||||||
|
|||||||
@@ -338,8 +338,18 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||||
case errors.Is(err, ErrPublicIP):
|
case errors.Is(err, ErrPublicIP):
|
||||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||||
default:
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
||||||
|
// A cancellation or timeout is not a policy decision; surface it
|
||||||
|
// as-is so callers do not report it as a filtered request.
|
||||||
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||||
|
default:
|
||||||
|
// The host could not be resolved, so its address class cannot be
|
||||||
|
// verified. Fail closed and treat it as filtered, the same as a
|
||||||
|
// host that resolves to a blocked address, so clients get a
|
||||||
|
// generic 403 rather than a 500. This also denies alternate IP
|
||||||
|
// encodings such as http://2130706433/ that the resolver rejects
|
||||||
|
// as a hostname but Chromium would read as a private IP.
|
||||||
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,39 @@ func TestFilterOutboundURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_UnresolvableHostFailsClosed(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, errors.New("no such host")
|
||||||
|
})
|
||||||
|
|
||||||
|
// An alternate IP encoding (decimal for 127.0.0.1) that the resolver
|
||||||
|
// rejects as a hostname must fail closed as filtered, not surface as a
|
||||||
|
// server error, so clients receive a generic 403.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://2130706433/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if !errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("expected ErrFiltered, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_ResolverCancellationNotFiltered(t *testing.T) {
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return nil, context.Canceled
|
||||||
|
})
|
||||||
|
|
||||||
|
// A cancellation or timeout is not a policy decision and must not be
|
||||||
|
// reported as a filtered request.
|
||||||
|
_, err := DecideOutbound(context.Background(), "http://example.com/", nil, nil, time.Now().Add(5*time.Second), WithDenyPrivateIPs(true))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("cancellation must not be filtered, got: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("expected context.Canceled, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
|
||||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
|
|||||||
@@ -178,6 +178,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
return nil, cancel, fmt.Errorf("get multipart form: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
err := form.RemoveAll()
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("remove multipart temporary files: %s", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// This will ensure we do not exceed the body limit.
|
// This will ensure we do not exceed the body limit.
|
||||||
var formValuesSize int64
|
var formValuesSize int64
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -73,6 +74,64 @@ func TestNewContext_Cancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
part, err := writer.CreateFormFile("files", "input.odt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create multipart file: %v", err)
|
||||||
|
}
|
||||||
|
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write multipart file: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
err = req.ParseMultipartForm(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse multipart form: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = req.MultipartForm.RemoveAll()
|
||||||
|
}()
|
||||||
|
|
||||||
|
upload, err := req.MultipartForm.File["files"][0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
temporaryFile, ok := upload.(*os.File)
|
||||||
|
if !ok {
|
||||||
|
_ = upload.Close()
|
||||||
|
t.Fatal("multipart upload is not disk-backed")
|
||||||
|
}
|
||||||
|
temporaryPath := temporaryFile.Name()
|
||||||
|
err = temporaryFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close disk-backed multipart file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
downloadFromCfg := downloadFromConfig{disable: true}
|
||||||
|
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newContext returned error: %v", err)
|
||||||
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = os.Stat(temporaryPath)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Concurrent downloadFrom entries must not race on the shared maps
|
// Concurrent downloadFrom entries must not race on the shared maps
|
||||||
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
||||||
// to catch the data race; without -race a sufficient number of entries
|
// to catch the data race; without -race a sufficient number of entries
|
||||||
|
|||||||
@@ -86,13 +86,37 @@ func ParseError(err error) (int, string) {
|
|||||||
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statusClientClosedRequest is the non-standard 499 status (nginx convention)
|
||||||
|
// recorded when the client aborts the request before it completes. It keeps
|
||||||
|
// such outcomes out of the 5xx range in the access log and Prometheus metrics.
|
||||||
|
const statusClientClosedRequest = 499
|
||||||
|
|
||||||
|
// requestCanceled reports whether err is the result of the client aborting the
|
||||||
|
// request rather than a server-side failure. It requires both that err wraps
|
||||||
|
// [context.Canceled] and that the request context itself was canceled, so a
|
||||||
|
// context.Canceled originating elsewhere still surfaces as an internal error.
|
||||||
|
// A server-side timeout is [context.DeadlineExceeded], mapped to 503 by
|
||||||
|
// [ParseError], and is deliberately not treated as a client abort.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func requestCanceled(c echo.Context, err error) bool {
|
||||||
|
return errors.Is(err, context.Canceled) && errors.Is(c.Request().Context().Err(), context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
// httpErrorHandler is the centralized HTTP error handler. It parses the error,
|
||||||
// returns a response as "text/plain; charset=UTF-8".
|
// returns a response as "text/plain; charset=UTF-8".
|
||||||
func httpErrorHandler() echo.HTTPErrorHandler {
|
func httpErrorHandler() echo.HTTPErrorHandler {
|
||||||
return func(err error, c echo.Context) {
|
return func(err error, c echo.Context) {
|
||||||
logger := c.Get("logger").(*slog.Logger)
|
logger := c.Get("logger").(*slog.Logger)
|
||||||
status, message := ParseError(err)
|
|
||||||
|
|
||||||
|
if requestCanceled(c, err) {
|
||||||
|
// The client is gone, so writing a body would only fail and add
|
||||||
|
// noise. Record the status so the access log and metrics classify
|
||||||
|
// it as a client abort rather than an internal error.
|
||||||
|
c.Response().WriteHeader(statusClientClosedRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := ParseError(err)
|
||||||
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
|
||||||
|
|
||||||
err = c.String(status, message)
|
err = c.String(status, message)
|
||||||
@@ -263,9 +287,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
finishTime := time.Now()
|
finishTime := time.Now()
|
||||||
|
|
||||||
status := c.Response().Status
|
status := c.Response().Status
|
||||||
|
canceled := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
canceled = requestCanceled(c, err)
|
||||||
|
if canceled {
|
||||||
|
status = statusClientClosedRequest
|
||||||
|
} else {
|
||||||
parsedStatus, _ := ParseError(err)
|
parsedStatus, _ := ParseError(err)
|
||||||
status = parsedStatus
|
status = parsedStatus
|
||||||
|
}
|
||||||
|
|
||||||
span.SetAttributes(attribute.String("error", err.Error()))
|
span.SetAttributes(attribute.String("error", err.Error()))
|
||||||
c.Error(err)
|
c.Error(err)
|
||||||
@@ -293,10 +323,15 @@ func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader st
|
|||||||
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
With(slog.Int64("bytes_in", c.Request().ContentLength)).
|
||||||
With(slog.Int64("bytes_out", c.Response().Size))
|
With(slog.Int64("bytes_out", c.Response().Size))
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
accessLogger.ErrorContext(ctx, err.Error())
|
case err == nil:
|
||||||
} else {
|
|
||||||
accessLogger.InfoContext(ctx, "request handled")
|
accessLogger.InfoContext(ctx, "request handled")
|
||||||
|
case canceled:
|
||||||
|
// A client abort is expected, not a server failure; keep it
|
||||||
|
// visible but out of the error stream.
|
||||||
|
accessLogger.InfoContext(ctx, err.Error())
|
||||||
|
default:
|
||||||
|
accessLogger.ErrorContext(ctx, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
additionalAttributes := []attribute.KeyValue{
|
additionalAttributes := []attribute.KeyValue{
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +14,70 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestRequestCanceled pins the client-abort discriminator: only a
|
||||||
|
// context.Canceled that stems from the request context counts, so a server
|
||||||
|
// timeout or an unrelated cancellation still surfaces as an internal failure.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1627.
|
||||||
|
func TestRequestCanceled(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
timedOut, cancelTimeout := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
||||||
|
defer cancelTimeout()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, context.Canceled, true},
|
||||||
|
{"wrapped client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), true},
|
||||||
|
{"canceled error but live request", context.Background(), context.Canceled, false},
|
||||||
|
{"canceled request but unrelated error", canceled, errors.New("boom"), false},
|
||||||
|
{"server timeout is not a client abort", timedOut, context.DeadlineExceeded, false},
|
||||||
|
{"no error", canceled, nil, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
if got := requestCanceled(c, tc.err); got != tc.want {
|
||||||
|
t.Fatalf("requestCanceled = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHttpErrorHandler_ClientClosedRequest ensures a client abort is recorded
|
||||||
|
// as 499 rather than 500, and that a genuine failure keeps its status.
|
||||||
|
func TestHttpErrorHandler_ClientClosedRequest(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
reqCtx context.Context
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"client abort", canceled, fmt.Errorf("convert to PDF: %w", context.Canceled), statusClientClosedRequest},
|
||||||
|
{"internal failure", context.Background(), errors.New("boom"), http.StatusInternalServerError},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(tc.reqCtx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := echo.New().NewContext(req, rec)
|
||||||
|
c.Set("logger", slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
httpErrorHandler()(tc.err, c)
|
||||||
|
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestOutputFilenameMiddleware pins the sanitizing of the
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||||
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||||
// a Content-Disposition header, so a path separator must never survive it.
|
// a Content-Disposition header, so a path separator must never survive it.
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type browserArguments struct {
|
|||||||
denyPublicIPs bool
|
denyPublicIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
|
clearStorage bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +180,28 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
return fmt.Errorf("start pinning proxy: %w", err)
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||||
|
|
||||||
|
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||||
|
// Chromium implicitly bypasses the proxy for loopback and
|
||||||
|
// link-local destinations. A WebSocket handshake is never surfaced
|
||||||
|
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||||
|
// cannot filter it; the pinning proxy is the only layer that sees
|
||||||
|
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||||
|
// localhost, or the link-local cloud metadata endpoint
|
||||||
|
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||||
|
// the implicit bypass so those handshakes also traverse the pinning
|
||||||
|
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||||
|
// request.
|
||||||
|
//
|
||||||
|
// Gated on the IP-class policy: it is the control this closes, and
|
||||||
|
// under it loopback and link-local HTTP sub-resources are already
|
||||||
|
// blocked by listenForEventRequestPaused before they would reach
|
||||||
|
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||||
|
// the policy is off, loopback is not restricted, and routing it
|
||||||
|
// through the proxy would merely change how an unreachable loopback
|
||||||
|
// sub-resource reports its failure.
|
||||||
|
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
@@ -200,7 +223,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
if stopErr != nil {
|
if stopErr != nil {
|
||||||
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("run exec allocator: %w", err)
|
return fmt.Errorf("run exec allocator: %w; if Chromium is slow to start, raise --chromium-start-timeout (currently %s)", err, b.arguments.wsUrlReadTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ctxMu.Lock()
|
b.ctxMu.Lock()
|
||||||
@@ -345,6 +368,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, out
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -371,6 +395,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
|
|||||||
runtime.Enable(),
|
runtime.Enable(),
|
||||||
clearCacheActionFunc(logger, b.arguments.clearCache),
|
clearCacheActionFunc(logger, b.arguments.clearCache),
|
||||||
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
clearCookiesActionFunc(logger, b.arguments.clearCookies),
|
||||||
|
clearStorageActionFunc(logger, b.arguments.clearStorage, url),
|
||||||
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
|
||||||
setCookiesActionFunc(logger, options.Cookies),
|
setCookiesActionFunc(logger, options.Cookies),
|
||||||
userAgentOverride(logger, options.UserAgent),
|
userAgentOverride(logger, options.UserAgent),
|
||||||
@@ -434,6 +459,17 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// WebSocket handshakes never surface as fetch.EventRequestPaused, so
|
||||||
|
// listenForEventRequestPaused above cannot filter them. Validate them
|
||||||
|
// against the same allow / deny lists and IP-class policy.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
listenForEventWebSocketCreated(taskCtx, logger, eventWebSocketCreatedOptions{
|
||||||
|
allowList: b.arguments.allowList,
|
||||||
|
denyList: b.arguments.denyList,
|
||||||
|
denyPrivateIPs: b.arguments.denyPrivateIPs,
|
||||||
|
denyPublicIPs: b.arguments.denyPublicIPs,
|
||||||
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidHttpStatusCode error
|
invalidHttpStatusCode error
|
||||||
invalidHttpStatusCodeMu sync.RWMutex
|
invalidHttpStatusCodeMu sync.RWMutex
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ var (
|
|||||||
// or undefined.
|
// or undefined.
|
||||||
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
ErrInvalidSelectorQuery = errors.New("invalid selector query")
|
||||||
|
|
||||||
|
// ErrScreenshotSelectorNotFound happens when the CSS selector of a
|
||||||
|
// screenshot matches no element with a rendered box.
|
||||||
|
ErrScreenshotSelectorNotFound = errors.New("screenshot selector not found")
|
||||||
|
|
||||||
// ErrRpccMessageTooLarge happens when the messages received by
|
// ErrRpccMessageTooLarge happens when the messages received by
|
||||||
// ChromeDevTools are larger than 100 MB.
|
// ChromeDevTools are larger than 100 MB.
|
||||||
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
|
||||||
@@ -347,6 +351,11 @@ type ScreenshotOptions struct {
|
|||||||
// dimensions.
|
// dimensions.
|
||||||
Clip bool
|
Clip bool
|
||||||
|
|
||||||
|
// Selector clips the screenshot to the bounding box of the first element
|
||||||
|
// matching this CSS selector. Empty captures the whole page. Takes
|
||||||
|
// precedence over Clip.
|
||||||
|
Selector string
|
||||||
|
|
||||||
// Format is the image compression format, either "png" or "jpeg" or
|
// Format is the image compression format, either "png" or "jpeg" or
|
||||||
// "webp".
|
// "webp".
|
||||||
Format string
|
Format string
|
||||||
@@ -370,6 +379,7 @@ func DefaultScreenshotOptions() ScreenshotOptions {
|
|||||||
Width: 800,
|
Width: 800,
|
||||||
Height: 600,
|
Height: 600,
|
||||||
Clip: false,
|
Clip: false,
|
||||||
|
Selector: "",
|
||||||
Format: "png",
|
Format: "png",
|
||||||
Quality: 100,
|
Quality: 100,
|
||||||
OptimizeForSpeed: false,
|
OptimizeForSpeed: false,
|
||||||
@@ -467,6 +477,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network")
|
||||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
|
fs.Bool("chromium-clear-storage", false, "Clear Chromium local storage between each conversion (session storage is already isolated per conversion)")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
fs.Bool("chromium-disable-routes", false, "Disable the routes")
|
||||||
|
|
||||||
@@ -518,6 +529,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
|
clearStorage: flags.MustBool("chromium-clear-storage"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,54 @@ func listenForNetworkActivity(ctx context.Context, aggregate *networkAggregate)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type eventWebSocketCreatedOptions struct {
|
||||||
|
allowList, denyList []*regexp2.Regexp
|
||||||
|
denyPrivateIPs bool
|
||||||
|
denyPublicIPs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// listenForEventWebSocketCreated validates the target of every WebSocket
|
||||||
|
// handshake against the same allow / deny lists and IP-class policy as
|
||||||
|
// [listenForEventRequestPaused]. Chromium never surfaces a WebSocket
|
||||||
|
// handshake as a fetch.EventRequestPaused, so without this listener a page
|
||||||
|
// could open a WebSocket to an address the outbound filter would otherwise
|
||||||
|
// block. See https://github.com/gotenberg/gotenberg/issues/1011.
|
||||||
|
//
|
||||||
|
// This listener records an operator-visible warning with the full ws:// URL.
|
||||||
|
// The connection itself is severed by the pinning proxy, which every
|
||||||
|
// WebSocket handshake traverses once the implicit loopback / link-local proxy
|
||||||
|
// bypass is removed (see the "<-loopback>" flag in browser.go). When the
|
||||||
|
// operator configures a custom proxy or host-resolver mappings, the pinning
|
||||||
|
// proxy is not started; the WebSocket then follows the operator's egress path
|
||||||
|
// and this warning is the remaining safeguard, since a WebSocket handshake
|
||||||
|
// cannot be aborted through the CDP Network domain.
|
||||||
|
func listenForEventWebSocketCreated(ctx context.Context, logger *slog.Logger, options eventWebSocketCreatedOptions) {
|
||||||
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
|
e, ok := ev.(*network.EventWebSocketCreated)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("event EventWebSocketCreated fired for '%s'", e.URL))
|
||||||
|
|
||||||
|
deadline, ok := ctx.Deadline()
|
||||||
|
if !ok {
|
||||||
|
logger.ErrorContext(ctx, "context has no deadline, cannot filter WebSocket URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := gotenberg.FilterOutboundURL(ctx, e.URL, options.allowList, options.denyList, deadline,
|
||||||
|
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
|
||||||
|
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
denyPrivateIPs bool
|
denyPrivateIPs bool
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
var (
|
var (
|
||||||
width, height int
|
width, height int
|
||||||
clip bool
|
clip bool
|
||||||
|
selector string
|
||||||
format string
|
format string
|
||||||
quality int
|
quality int
|
||||||
optimizeForSpeed bool
|
optimizeForSpeed bool
|
||||||
@@ -376,6 +377,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Int("width", &width, defaultScreenshotOptions.Width).
|
Int("width", &width, defaultScreenshotOptions.Width).
|
||||||
Int("height", &height, defaultScreenshotOptions.Height).
|
Int("height", &height, defaultScreenshotOptions.Height).
|
||||||
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
Bool("clip", &clip, defaultScreenshotOptions.Clip).
|
||||||
|
String("selector", &selector, defaultScreenshotOptions.Selector).
|
||||||
Custom("format", func(value string) error {
|
Custom("format", func(value string) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
format = defaultScreenshotOptions.Format
|
format = defaultScreenshotOptions.Format
|
||||||
@@ -420,6 +422,7 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Clip: clip,
|
Clip: clip,
|
||||||
|
Selector: selector,
|
||||||
Format: format,
|
Format: format,
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
OptimizeForSpeed: optimizeForSpeed,
|
OptimizeForSpeed: optimizeForSpeed,
|
||||||
@@ -963,7 +966,17 @@ func screenshotUrl(ctx *api.Context, chromium Api, url string, options Screensho
|
|||||||
outputPath := ctx.GeneratePath(ext)
|
outputPath := ctx.GeneratePath(ext)
|
||||||
|
|
||||||
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
err := chromium.Screenshot(ctx, ctx.Log(), url, outputPath, options)
|
||||||
|
if errors.Is(err, ErrScreenshotSelectorNotFound) {
|
||||||
|
err = api.WrapError(
|
||||||
|
err,
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("The selector '%s' (selector) matched no element with a visible box", options.Selector),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
err = handleChromiumError(err, options.Options)
|
err = handleChromiumError(err, options.Options)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("screenshot: %w", err)
|
return fmt.Errorf("screenshot: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/emulation"
|
"github.com/chromedp/cdproto/emulation"
|
||||||
"github.com/chromedp/cdproto/network"
|
"github.com/chromedp/cdproto/network"
|
||||||
"github.com/chromedp/cdproto/page"
|
"github.com/chromedp/cdproto/page"
|
||||||
|
"github.com/chromedp/cdproto/storage"
|
||||||
"github.com/chromedp/chromedp"
|
"github.com/chromedp/chromedp"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
@@ -54,6 +57,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
err := func() error {
|
err := func() error {
|
||||||
|
paperWidth := options.PaperWidth
|
||||||
paperHeight := options.PaperHeight
|
paperHeight := options.PaperHeight
|
||||||
pageRanges := options.PageRanges
|
pageRanges := options.PageRanges
|
||||||
|
|
||||||
@@ -67,9 +71,20 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
|
|
||||||
// There are 96 CSS pixels per inch.
|
// There are 96 CSS pixels per inch.
|
||||||
// See https://issues.chromium.org/issues/40267771#comment14.
|
// See https://issues.chromium.org/issues/40267771#comment14.
|
||||||
|
if options.Landscape {
|
||||||
|
// Landscape swaps the paper dimensions, so the page is
|
||||||
|
// WithPaperHeight wide by WithPaperWidth tall. Size both to
|
||||||
|
// the content so the width expands to fit a wide document
|
||||||
|
// (e.g. a table) instead of the height-only expansion
|
||||||
|
// landing on the width axis and truncating it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
paperWidth = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
paperHeight = (cssContentSize.Width / 96) + options.MarginLeft + options.MarginRight
|
||||||
|
} else {
|
||||||
// We add top and bottom margins so that the content area
|
// We add top and bottom margins so that the content area
|
||||||
// is large enough to fit the entire content.
|
// is large enough to fit the entire content.
|
||||||
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
|
||||||
|
}
|
||||||
pageRanges = "1" // little dirty hack to avoid leftovers.
|
pageRanges = "1" // little dirty hack to avoid leftovers.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +93,7 @@ func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPat
|
|||||||
WithLandscape(options.Landscape).
|
WithLandscape(options.Landscape).
|
||||||
WithPrintBackground(options.PrintBackground).
|
WithPrintBackground(options.PrintBackground).
|
||||||
WithScale(options.Scale).
|
WithScale(options.Scale).
|
||||||
WithPaperWidth(options.PaperWidth).
|
WithPaperWidth(paperWidth).
|
||||||
WithPaperHeight(paperHeight).
|
WithPaperHeight(paperHeight).
|
||||||
WithMarginTop(options.MarginTop).
|
WithMarginTop(options.MarginTop).
|
||||||
WithMarginBottom(options.MarginBottom).
|
WithMarginBottom(options.MarginBottom).
|
||||||
@@ -188,7 +203,16 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
WithOptimizeForSpeed(options.OptimizeForSpeed).
|
||||||
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
WithFormat(page.CaptureScreenshotFormat(options.Format))
|
||||||
|
|
||||||
if options.Clip {
|
switch {
|
||||||
|
case options.Selector != "":
|
||||||
|
clip, err := elementClip(ctx, options.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clip screenshot to selector '%s'", options.Selector))
|
||||||
|
captureScreenshot = captureScreenshot.WithClip(clip)
|
||||||
|
case options.Clip:
|
||||||
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
captureScreenshot = captureScreenshot.WithClip(&page.Viewport{
|
||||||
Width: float64(options.Width),
|
Width: float64(options.Width),
|
||||||
Height: float64(options.Height),
|
Height: float64(options.Height),
|
||||||
@@ -229,6 +253,49 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// elementClip resolves the first element matching selector to a page-space clip
|
||||||
|
// rectangle for Page.captureScreenshot.
|
||||||
|
//
|
||||||
|
// getBoundingClientRect reports viewport-relative CSS pixels; adding the scroll
|
||||||
|
// offset puts the rectangle in the document coordinate space that
|
||||||
|
// WithCaptureBeyondViewport expects. It fails with
|
||||||
|
// [ErrScreenshotSelectorNotFound] when nothing matches or the match has no
|
||||||
|
// rendered box (display:none or a zero area), so the caller can answer 400.
|
||||||
|
func elementClip(ctx context.Context, selector string) (*page.Viewport, error) {
|
||||||
|
var rect struct {
|
||||||
|
Found bool `json:"found"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
expr := fmt.Sprintf(`(() => {
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) {
|
||||||
|
return { found: false };
|
||||||
|
}
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { found: true, x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height };
|
||||||
|
})()`, strconv.Quote(selector))
|
||||||
|
|
||||||
|
err := chromedp.Evaluate(expr, &rect).Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluate selector box: %v: %w", err, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
if !rect.Found || rect.Width <= 0 || rect.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("selector %q matched no element with a visible box: %w", selector, ErrScreenshotSelectorNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &page.Viewport{
|
||||||
|
X: rect.X,
|
||||||
|
Y: rect.Y,
|
||||||
|
Width: rect.Width,
|
||||||
|
Height: rect.Height,
|
||||||
|
Scale: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
logger.DebugContext(ctx, "set device metrics override")
|
logger.DebugContext(ctx, "set device metrics override")
|
||||||
@@ -280,6 +347,54 @@ func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clearStorageActionFunc clears the converted origin's local storage before the
|
||||||
|
// page loads, so state written by a previous conversion of the same origin does
|
||||||
|
// not leak into this one. See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
//
|
||||||
|
// Session storage is not touched: each conversion runs in its own browsing
|
||||||
|
// context (a fresh tab), so it is already isolated and cannot leak. Local
|
||||||
|
// storage is per-origin and shared across tabs of the long-lived browser, so it
|
||||||
|
// is the only web storage that carries over.
|
||||||
|
func clearStorageActionFunc(logger *slog.Logger, clear bool, rawURL string) chromedp.ActionFunc {
|
||||||
|
return func(ctx context.Context) error {
|
||||||
|
if !clear {
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
origin, ok := httpOrigin(rawURL)
|
||||||
|
if !ok {
|
||||||
|
// A file:// upload gets an opaque, per-request origin that is not
|
||||||
|
// shared between conversions, so there is nothing to clear.
|
||||||
|
logger.DebugContext(ctx, "local storage not cleared: non-http(s) origin is already isolated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, fmt.Sprintf("clear local storage for %s", origin))
|
||||||
|
|
||||||
|
err := storage.ClearDataForOrigin(origin, string(storage.TypeLocalStorage)).Do(ctx)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("clear local storage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpOrigin returns the http(s) security origin (scheme://host[:port]) of
|
||||||
|
// rawURL, and false when rawURL is not http(s). A non-http(s) URL such as a
|
||||||
|
// file:// upload has an opaque origin that no other conversion shares.
|
||||||
|
func httpOrigin(rawURL string) (string, bool) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host), true
|
||||||
|
}
|
||||||
|
|
||||||
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/175.
|
// See https://github.com/gotenberg/gotenberg/issues/175.
|
||||||
|
|||||||
@@ -293,6 +293,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
return errors.New("LibreOffice not started, cannot handle PDF conversion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SinglePageSheets starts each sheet's single page at the workbook's saved
|
||||||
|
// scroll position, truncating everything above and to the left of it.
|
||||||
|
// Render a copy with that position reset to the top-left cell instead.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
if options.SinglePageSheets {
|
||||||
|
inputPath = resetCalcScrollPosition(ctx, logger, inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"--no-launch",
|
"--no-launch",
|
||||||
"--format",
|
"--format",
|
||||||
|
|||||||
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet
|
||||||
|
// uses (on <sheetView> and, for frozen panes, <pane>) to store the cell that
|
||||||
|
// was at the top-left of the window when the workbook was saved.
|
||||||
|
var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`)
|
||||||
|
|
||||||
|
// maxDecompressedWorksheet bounds how much a single worksheet may decompress to
|
||||||
|
// while rewriting it. It guards against a decompression bomb and keeps memory
|
||||||
|
// predictable. A worksheet larger than this is left untouched, so a pathological
|
||||||
|
// workbook falls back to the original file rather than being rewritten.
|
||||||
|
const maxDecompressedWorksheet = 128 << 20 // 128 MiB
|
||||||
|
|
||||||
|
// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet
|
||||||
|
// scroll positions have been reset to the top-left cell, or inputPath unchanged
|
||||||
|
// when the reset does not apply or cannot be performed safely.
|
||||||
|
//
|
||||||
|
// LibreOffice's SinglePageSheets export starts each single page at the sheet's
|
||||||
|
// saved topLeftCell, dropping every row and column above and to the left of it.
|
||||||
|
// A workbook saved scrolled away from A1 therefore renders truncated. Removing
|
||||||
|
// the attribute before the conversion makes the whole used range render.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
//
|
||||||
|
// The function never fails the conversion. On a non-xlsx input, a workbook that
|
||||||
|
// carries no scroll position, or any read, rewrite or validation error, it
|
||||||
|
// returns the original path so a malformed rewrite can never reach LibreOffice.
|
||||||
|
func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string {
|
||||||
|
// Resolve the extension to a literal so the sanitized filename is never
|
||||||
|
// derived from the (user-controlled) upload name.
|
||||||
|
var ext string
|
||||||
|
switch strings.ToLower(filepath.Ext(inputPath)) {
|
||||||
|
case ".xlsx":
|
||||||
|
ext = ".xlsx"
|
||||||
|
case ".xlsm":
|
||||||
|
ext = ".xlsm"
|
||||||
|
default:
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(src)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
// The common case: nothing was saved scrolled, so nothing to do.
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rewrite that dropped, renamed or corrupted an entry must never reach
|
||||||
|
// LibreOffice; fall back to the original workbook if it does not round-trip.
|
||||||
|
if err = validateWorkbook(src, out); err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the sanitized copy alongside the input, inside the request working
|
||||||
|
// directory that LibreOffice already reads from. The pattern is constant,
|
||||||
|
// so the resulting name carries no user-controlled path component.
|
||||||
|
dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
_, err = dst.Write(out)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(dst.Name())
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err))
|
||||||
|
return inputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export")
|
||||||
|
return dst.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx
|
||||||
|
// workbook, removing the topLeftCell attribute, and reports whether anything
|
||||||
|
// changed. Every non-worksheet entry, and every worksheet that does not carry
|
||||||
|
// the attribute, is copied byte-for-byte without recompression.
|
||||||
|
func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) {
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buf)
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
rewritten, ok, err := rewriteWorksheet(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// Recompress only the worksheets that actually changed.
|
||||||
|
header := file.FileHeader
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
w, err := writer.CreateHeader(&header)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = w.Write(rewritten)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err = copyZipEntry(writer, file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("finalize workbook: %w", err)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return buf.Bytes(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteWorksheet returns file's contents with topLeftCell removed, and
|
||||||
|
// whether file is a worksheet that carried the attribute. A worksheet without
|
||||||
|
// the attribute, or any other entry, returns ok false so the caller copies it
|
||||||
|
// verbatim.
|
||||||
|
func rewriteWorksheet(file *zip.File) ([]byte, bool, error) {
|
||||||
|
if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
// Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb
|
||||||
|
// cannot exhaust memory; a genuine overflow aborts the rewrite.
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
if len(data) > maxDecompressedWorksheet {
|
||||||
|
return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Contains(data, []byte("topLeftCell")) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return topLeftCellAttr.ReplaceAll(data, nil), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyZipEntry writes file into writer without decompressing and recompressing
|
||||||
|
// it, preserving its exact bytes.
|
||||||
|
func copyZipEntry(writer *zip.Writer, file *zip.File) error {
|
||||||
|
w, err := writer.CreateRaw(&file.FileHeader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
rc, err := file.OpenRaw()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(w, rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("copy entry %q: %w", file.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWorkbook checks that out reopens as a zip holding exactly the same
|
||||||
|
// entry names as src, rejecting a rewrite that lost, renamed or added an entry
|
||||||
|
// or produced a broken central directory. The entry payloads themselves are
|
||||||
|
// not re-read: unchanged entries are copied byte-for-byte from a workbook that
|
||||||
|
// already parsed, and rewritten worksheets are produced by the standard library
|
||||||
|
// writer, so re-decompressing everything would only add a decompression-bomb
|
||||||
|
// surface without catching a failure this transform can introduce.
|
||||||
|
func validateWorkbook(src, out []byte) error {
|
||||||
|
original, err := zip.NewReader(bytes.NewReader(src), int64(len(src)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen original workbook: %w", err)
|
||||||
|
}
|
||||||
|
rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen rewritten workbook: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rewritten.File) != len(original.File) {
|
||||||
|
return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File))
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]struct{}, len(original.File))
|
||||||
|
for _, file := range original.File {
|
||||||
|
names[file.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, file := range rewritten.File {
|
||||||
|
_, ok := names[file.Name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unexpected entry %q", file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildWorkbook packs entries into an in-memory xlsx-like zip.
|
||||||
|
func buildWorkbook(t *testing.T, entries map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
for name, content := range entries {
|
||||||
|
f, err := w.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close workbook: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEntry(t *testing.T, workbook []byte, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read entry %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
t.Fatalf("entry %q not found", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrolledSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
|
||||||
|
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
func TestStripWorksheetScrollPosition(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
workbook []byte
|
||||||
|
expectErr bool
|
||||||
|
expectChange bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scenario: "removes topLeftCell from sheetView and pane",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
"xl/sharedStrings.xml": "<sst/>",
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "leaves a workbook without a saved scroll position untouched",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}),
|
||||||
|
expectChange: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "only rewrites worksheet entries",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
// A stray topLeftCell elsewhere must not be touched.
|
||||||
|
"xl/workbook.xml": `<workbook topLeftCell="A9"/>`,
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "rejects a non-zip input",
|
||||||
|
workbook: []byte("not a zip file"),
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
out, changed, err := stripWorksheetScrollPosition(tc.workbook)
|
||||||
|
|
||||||
|
if tc.expectErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if changed != tc.expectChange {
|
||||||
|
t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rewrite must round-trip and hold the same entries.
|
||||||
|
if err = validateWorkbook(tc.workbook, out); err != nil {
|
||||||
|
t.Fatalf("rewritten workbook did not validate: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
// Non-worksheet entries are copied verbatim.
|
||||||
|
if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok {
|
||||||
|
if got := readEntry(t, out, "xl/workbook.xml"); got != `<workbook topLeftCell="A9"/>` {
|
||||||
|
t.Fatalf("non-worksheet entry was modified: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func entryNames(t *testing.T, workbook []byte) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open workbook: %v", err)
|
||||||
|
}
|
||||||
|
names := make(map[string]struct{}, len(r.File))
|
||||||
|
for _, f := range r.File {
|
||||||
|
names[f.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetCalcScrollPosition(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeFile := func(t *testing.T, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %q: %v", name, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-xlsx input is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.docx", []byte("whatever"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"xl/worksheets/sheet1.xml": topSheet,
|
||||||
|
}))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected original path %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip"))
|
||||||
|
if got := resetCalcScrollPosition(ctx, logger, path); got != path {
|
||||||
|
t.Fatalf("expected fallback to %q, got %q", path, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) {
|
||||||
|
path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
}))
|
||||||
|
got := resetCalcScrollPosition(ctx, logger, path)
|
||||||
|
if got == path {
|
||||||
|
t.Fatalf("expected a sanitized copy, got the original path")
|
||||||
|
}
|
||||||
|
if filepath.Dir(got) != filepath.Dir(path) {
|
||||||
|
t.Fatalf("sanitized copy escaped the working directory: %q", got)
|
||||||
|
}
|
||||||
|
sanitized, err := os.ReadFile(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sanitized file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") {
|
||||||
|
t.Fatalf("sanitized worksheet still contains topLeftCell")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -454,7 +454,7 @@ func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(ctx, logger, objects, metadata)
|
||||||
if len(filespecRefs) == 0 {
|
if len(filespecRefs) == 0 {
|
||||||
span.SetStatus(codes.Ok, "")
|
span.SetStatus(codes.Ok, "")
|
||||||
return nil
|
return nil
|
||||||
@@ -506,7 +506,7 @@ func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
|
|||||||
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
|
||||||
// and returns the catalog reference, catalog value, filespec references,
|
// and returns the catalog reference, catalog value, filespec references,
|
||||||
// and the update objects map.
|
// and the update objects map.
|
||||||
func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
func patchFilespecMetadata(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
var catalogRef string
|
var catalogRef string
|
||||||
var catalogValue map[string]any
|
var catalogValue map[string]any
|
||||||
@@ -556,7 +556,7 @@ func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessa
|
|||||||
if ef, ok := value["/EF"].(map[string]any); ok {
|
if ef, ok := value["/EF"].(map[string]any); ok {
|
||||||
efRef, _ := ef["/F"].(string)
|
efRef, _ := ef["/F"].(string)
|
||||||
if efRef != "" {
|
if efRef != "" {
|
||||||
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
|
setStreamSubtype(ctx, logger, objects, updateObjects, efRef, mimeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,38 +653,38 @@ func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger
|
|||||||
|
|
||||||
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
// setStreamSubtype finds a stream object by reference and sets the /Subtype
|
||||||
// key in its dict.
|
// key in its dict.
|
||||||
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
func setStreamSubtype(ctx context.Context, logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
|
||||||
objKey := ref
|
objKey := ref
|
||||||
if !strings.HasPrefix(objKey, "obj:") {
|
if !strings.HasPrefix(objKey, "obj:") {
|
||||||
objKey = "obj:" + objKey
|
objKey = "obj:" + objKey
|
||||||
}
|
}
|
||||||
raw, ok := objects[objKey]
|
raw, ok := objects[objKey]
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: object not found", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var obj map[string]json.RawMessage
|
var obj map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
streamRaw, ok := obj["stream"]
|
streamRaw, ok := obj["stream"]
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: no stream key", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
if err := json.Unmarshal(streamRaw, &stream); err != nil {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dict, ok := stream["dict"].(map[string]any)
|
dict, ok := stream["dict"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
logger.WarnContext(ctx, fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package qpdf
|
package qpdf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -99,7 +100,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Data"},
|
"factur-x.xml": {"relationship": "Data"},
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
|
|
||||||
if catalogRef != "obj:1 0 R" {
|
if catalogRef != "obj:1 0 R" {
|
||||||
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
|
||||||
@@ -125,7 +126,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Data"},
|
"factur-x.xml": {"relationship": "Data"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, _ := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 0 {
|
if len(filespecRefs) != 0 {
|
||||||
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"relationship": "Alternative"},
|
"factur-x.xml": {"relationship": "Alternative"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
_, _, filespecRefs, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
if len(filespecRefs) != 1 {
|
if len(filespecRefs) != 1 {
|
||||||
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,7 @@ func TestPatchFilespecMetadata(t *testing.T) {
|
|||||||
"factur-x.xml": {"mimeType": "text/xml"},
|
"factur-x.xml": {"mimeType": "text/xml"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
|
_, _, _, updateObjects := patchFilespecMetadata(context.Background(), logger, objects, metadata)
|
||||||
streamObj, ok := updateObjects["obj:3 0 R"]
|
streamObj, ok := updateObjects["obj:3 0 R"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected obj:3 0 R in updateObjects")
|
t.Fatal("expected obj:3 0 R in updateObjects")
|
||||||
@@ -223,7 +224,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||||
|
|
||||||
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
|
||||||
dict := streamObj["dict"].(map[string]any)
|
dict := streamObj["dict"].(map[string]any)
|
||||||
@@ -238,7 +239,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "5 0 R", "application/pdf")
|
||||||
|
|
||||||
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
if _, ok := updateObjects["obj:5 0 R"]; !ok {
|
||||||
t.Error("expected obj:5 0 R in updateObjects")
|
t.Error("expected obj:5 0 R in updateObjects")
|
||||||
@@ -249,7 +250,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
objects := map[string]json.RawMessage{}
|
objects := map[string]json.RawMessage{}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:99 0 R", "text/xml")
|
||||||
|
|
||||||
if len(updateObjects) != 0 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for missing object")
|
t.Error("expected no updates for missing object")
|
||||||
@@ -262,7 +263,7 @@ func TestSetStreamSubtype(t *testing.T) {
|
|||||||
}
|
}
|
||||||
updateObjects := make(map[string]any)
|
updateObjects := make(map[string]any)
|
||||||
|
|
||||||
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
setStreamSubtype(context.Background(), logger, objects, updateObjects, "obj:3 0 R", "text/xml")
|
||||||
|
|
||||||
if len(updateObjects) != 0 {
|
if len(updateObjects) != 0 {
|
||||||
t.Error("expected no updates for non-stream object")
|
t.Error("expected no updates for non-stream object")
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ Available tags:
|
|||||||
|
|
||||||
| Group | Tags |
|
| Group | Tags |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
|
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url`, `chromium-ssrf` |
|
||||||
| LibreOffice | `libreoffice`, `libreoffice-convert` |
|
| LibreOffice | `libreoffice`, `libreoffice-convert`, `libreoffice-ssrf` |
|
||||||
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
|
||||||
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ Feature: /forms/chromium/convert/html
|
|||||||
Page 12
|
Page 12
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# A wide table on one landscape page: the page must expand its width to the
|
||||||
|
# content, so the rightmost column is not truncated and the page comes out
|
||||||
|
# landscape instead of the narrow-tall strip the height-only expansion used
|
||||||
|
# to produce. See https://github.com/gotenberg/gotenberg/issues/1390.
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Single Page Landscape)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/wide-table-html/index.html | file |
|
||||||
|
| singlePage | true | field |
|
||||||
|
| landscape | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" PDF should have 1 page(s)
|
||||||
|
Then the "foo.pdf" PDF should be set to landscape orientation
|
||||||
|
Then the "foo.pdf" PDF should have content matching "Column 12" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Landscape)
|
Scenario: POST /forms/chromium/convert/html (Landscape)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
@@ -416,6 +434,87 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then the Gotenberg container should log the following entries:
|
Then the Gotenberg container should log the following entries:
|
||||||
| 'file:///etc/passwd' matches the expression from the denied list |
|
| 'file:///etc/passwd' matches the expression from the denied list |
|
||||||
|
|
||||||
|
# Control for the WebSocket scenario below. An ordinary fetch to a loopback
|
||||||
|
# address is surfaced as a Fetch.requestPaused event, so it is blocked by
|
||||||
|
# CHROMIUM_DENY_PRIVATE_IPS and the block is logged. The allow-list is
|
||||||
|
# cleared because a matching allow-list entry bypasses the IP-based check.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Fetch to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-fetch-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/ssrf-fetch' targets a non-public address |
|
||||||
|
|
||||||
|
# A WebSocket handshake is never surfaced as a Fetch.requestPaused event, so
|
||||||
|
# it escapes the filter in listenForEventRequestPaused. The page opens
|
||||||
|
# WebSockets to two non-public addresses (loopback and the link-local cloud
|
||||||
|
# metadata IP). listenForEventWebSocketCreated logs each disallowed handshake
|
||||||
|
# with its full ws:// URL (detection), and the pinning proxy severs the
|
||||||
|
# connection now that the implicit loopback bypass is removed (enforcement).
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# A Web Worker is a separate CDP target, so its WebSocket handshake is not
|
||||||
|
# observed by listenForEventWebSocketCreated. Enforcement must not depend on
|
||||||
|
# that listener: the pinning proxy sees the handshake and severs it whatever
|
||||||
|
# the originating context. Only the proxy's block is asserted, since no
|
||||||
|
# detection log is produced for the worker target.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (WebSocket from a Web Worker is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-worker-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same path
|
||||||
|
# as ws://, and must be filtered identically.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Secure WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-tls-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
||||||
|
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (EventSource to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-eventsource-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/sse' targets a non-public address |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
|
Scenario: POST /forms/chromium/convert/html (Main URL does NOT match allowed list)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
| CHROMIUM_ALLOW_LIST | ^file:(?!//\\/tmp/).* |
|
||||||
|
|||||||
@@ -22,6 +22,41 @@ Feature: /forms/chromium/convert/url
|
|||||||
Page 1
|
Page 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# localStorage is per-origin and shared by the long-lived browser, so without
|
||||||
|
# clearing it accumulates across same-origin conversions.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (localStorage leaks without clearing)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=2" at page 1
|
||||||
|
|
||||||
|
# With --chromium-clear-storage every conversion clears the origin's
|
||||||
|
# localStorage first, so the counter never carries over.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/919.
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Clear Storage)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_CLEAR_STORAGE | true |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/local-storage-html/index.html | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the "foo.pdf" PDF should have content matching "localStorageCount=1" at page 1
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Single Page)
|
Scenario: POST /forms/chromium/convert/url (Single Page)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
@@ -499,6 +534,7 @@ Feature: /forms/chromium/convert/url
|
|||||||
Then the response header "Content-Type" should be "application/pdf"
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
|
@chromium-ssrf
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
Scenario: POST /forms/chromium/convert/url (Main URL is a non-public IP literal, deny-private-ips on)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | |
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
@@ -512,6 +548,55 @@ Feature: /forms/chromium/convert/url
|
|||||||
Forbidden
|
Forbidden
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# IPv6 loopback literal is parsed as an IP and rejected by the IP-class check,
|
||||||
|
# like the IPv4 loopback literal above.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL is an IPv6 loopback literal, deny-private-ips on)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://[::1]/ | field |
|
||||||
|
Then the response status code should be 403
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Forbidden
|
||||||
|
"""
|
||||||
|
|
||||||
|
# An alternate IP encoding (decimal for 127.0.0.1) that Chromium would read
|
||||||
|
# as loopback but the resolver rejects as a hostname. It must fail closed as
|
||||||
|
# filtered (a generic 403), not surface as a 500.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL is a decimal-encoded loopback IP, deny-private-ips on)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://2130706433/ | field |
|
||||||
|
Then the response status code should be 403
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Forbidden
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A classic SSRF vector: an allow-listed URL that redirects to an internal
|
||||||
|
# address. The redirected request must not inherit the initial URL's
|
||||||
|
# allow-list pass. listenForEventRequestPaused re-validates it; it does not
|
||||||
|
# match the allow-list, so it is blocked (Chromium reports ERR_ACCESS_DENIED
|
||||||
|
# and the conversion renders the resulting error page).
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Redirect to a non-allow-listed address is re-filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | ^https?://host.docker.internal.* |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/redirect-to-private | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'http://127.0.0.1:9999/redirected' does not match any expression from the allowed list |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
|
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass)
|
||||||
Given I have a Gotenberg container with the following environment variable(s):
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
| CHROMIUM_ALLOW_LIST | .+ |
|
| CHROMIUM_ALLOW_LIST | .+ |
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@chromium-screenshot-html
|
@chromium-screenshot-html
|
||||||
Feature: /forms/chromium/screenshot/html
|
Feature: /forms/chromium/screenshot/html
|
||||||
|
|
||||||
|
# Route parity: the WebSocket outbound filter lives in the shared browser
|
||||||
|
# code path, so the screenshot route enforces it exactly like conversion.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (WebSocket to a non-public address is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_DENY_PRIVATE_IPS | true |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'ws://127.0.0.1:9999/ssrf-websocket' targets a non-public address |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Default)
|
Scenario: POST /forms/chromium/screenshot/html (Default)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
@@ -53,6 +69,32 @@ Feature: /forms/chromium/screenshot/html
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then the response header "Content-Type" should be "image/png"
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
|
||||||
|
# The target element is 300x200 and sits below a spacer, so a correct clip
|
||||||
|
# proves both the element size and its page offset. See issue #947.
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #target | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "image/png"
|
||||||
|
Then the "foo.png" image should be 300x200 pixels
|
||||||
|
# A centered red pixel proves the clip landed on the element, not on the
|
||||||
|
# white spacer above it, i.e. the page offset was applied.
|
||||||
|
Then the "foo.png" image pixel at 150,100 should be "#ff0000"
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/screenshot/html (Selector Not Found)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/screenshot-selector-html/index.html | file |
|
||||||
|
| selector | #does-not-exist | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response body should contain string:
|
||||||
|
"""
|
||||||
|
The selector '#does-not-exist' (selector) matched no element with a visible box
|
||||||
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
Scenario: POST /forms/chromium/screenshot/html (Quality)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
@@ -221,6 +222,7 @@ Feature: /debug
|
|||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
"chromium-clear-storage": "false",
|
||||||
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
|
||||||
"chromium-deny-private-ips": "false",
|
"chromium-deny-private-ips": "false",
|
||||||
"chromium-deny-public-ips": "false",
|
"chromium-deny-public-ips": "false",
|
||||||
|
|||||||
@@ -942,7 +942,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
|
|
||||||
# An embedded image is stored inside the document, not linked, so blocking
|
# An embedded image is stored inside the document, not linked, so blocking
|
||||||
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
# untrusted linked content leaves it untouched. Guards against over-blocking.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
Scenario: POST /forms/libreoffice/convert (Embedded Image Survives)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -955,7 +955,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
# An uploaded document always loads from an untrusted location, so soffice
|
# An uploaded document always loads from an untrusted location, so soffice
|
||||||
# refuses to resolve any content it links (absolute file:// path or external
|
# refuses to resolve any content it links (absolute file:// path or external
|
||||||
# URL). Closes the SSRF and local-file-read vector.
|
# URL). Closes the SSRF and local-file-read vector.
|
||||||
@libreoffice-linked-content
|
@libreoffice-ssrf
|
||||||
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
Scenario: POST /forms/libreoffice/convert (Linked External Resource Blocked)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
@@ -964,3 +964,17 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 0 image(s)
|
Then the "foo.pdf" PDF should have 0 image(s)
|
||||||
|
|
||||||
|
# The workbook was saved scrolled down to row 37. Without the topLeftCell
|
||||||
|
# reset, SinglePageSheets would start the page there and drop the header rows,
|
||||||
|
# so the "Meteor" column header only appears when the whole sheet is rendered.
|
||||||
|
# See https://github.com/gotenberg/gotenberg/issues/1222.
|
||||||
|
Scenario: POST /forms/libreoffice/convert (SinglePageSheets renders a scrolled workbook in full)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/singlepagesheets-scrolled.xlsx | file |
|
||||||
|
| singlePageSheets | true | field |
|
||||||
|
| Gotenberg-Output-Filename | foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
Then the "foo.pdf" PDF should have content matching "Meteor" at page 1
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/png" // Register the PNG decoder for image.DecodeConfig.
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -1010,6 +1012,51 @@ func (s *scenario) thePdfShouldHaveImages(ctx context.Context, name string, imag
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImageShouldBePixels(_ context.Context, name string, width, height int) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
config, format, err := image.DecodeConfig(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Width != width || config.Height != height {
|
||||||
|
return fmt.Errorf("expected %s image %dx%d, but actual is %dx%d", format, width, height, config.Width, config.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *scenario) theImagePixelShouldBe(_ context.Context, name string, x, y int, want string) error {
|
||||||
|
path := fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
|
||||||
|
|
||||||
|
file, err := os.Open(path) //nolint:gosec // path is built from test-controlled values.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, format, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode image %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
// RGBA returns 16-bit channels; shift down to the 8-bit hex form.
|
||||||
|
got := fmt.Sprintf("#%02x%02x%02x", r>>8, g>>8, b>>8)
|
||||||
|
if !strings.EqualFold(got, want) {
|
||||||
|
return fmt.Errorf("expected %s pixel at %d,%d to be %s, but actual is %s", format, x, y, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error {
|
||||||
var path string
|
var path string
|
||||||
if !strings.HasPrefix(name, "*_") {
|
if !strings.HasPrefix(name, "*_") {
|
||||||
@@ -1047,7 +1094,9 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
}
|
}
|
||||||
|
|
||||||
output = strings.ReplaceAll(output, " ", "")
|
output = strings.ReplaceAll(output, " ", "")
|
||||||
re := regexp.MustCompile(`Pagesize:(\d+)x(\d+).*`)
|
// The dimensions can be fractional (e.g. a content-sized single page),
|
||||||
|
// so match floats, not just integers.
|
||||||
|
re := regexp.MustCompile(`Pagesize:([\d.]+)x([\d.]+)`)
|
||||||
matches := re.FindStringSubmatch(output)
|
matches := re.FindStringSubmatch(output)
|
||||||
|
|
||||||
if len(matches) < 3 {
|
if len(matches) < 3 {
|
||||||
@@ -1056,22 +1105,22 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
|
|||||||
|
|
||||||
invert := kind == "should NOT"
|
invert := kind == "should NOT"
|
||||||
|
|
||||||
width, err := strconv.Atoi(matches[1])
|
width, err := strconv.ParseFloat(matches[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert width value %q to integer: %w", matches[1], err)
|
return fmt.Errorf("convert width value %q to float: %w", matches[1], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
height, err := strconv.Atoi(matches[2])
|
height, err := strconv.ParseFloat(matches[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("convert height value %q to integer: %w", matches[2], err)
|
return fmt.Errorf("convert height value %q to float: %w", matches[2], err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if invert && height < width {
|
if invert && height < width {
|
||||||
return fmt.Errorf("expected height %d to be greater than width %d", height, width)
|
return fmt.Errorf("expected height %g to be greater than width %g", height, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !invert && width < height {
|
if !invert && width < height {
|
||||||
return fmt.Errorf("expected width %d to be greater than height %d", width, height)
|
return fmt.Errorf("expected width %g to be greater than height %g", width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1599,6 +1648,8 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
|
|||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
|
||||||
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)
|
||||||
|
ctx.Then(`^the "([^"]*)" image should be (\d+)x(\d+) pixels$`, s.theImageShouldBePixels)
|
||||||
|
ctx.Then(`^the "([^"]*)" image pixel at (\d+),(\d+) should be "([^"]*)"$`, s.theImagePixelShouldBe)
|
||||||
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) {
|
||||||
if s.gotenbergContainer != nil {
|
if s.gotenbergContainer != nil {
|
||||||
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
errTerminate := s.gotenbergContainer.Terminate(ctx, testcontainers.StopTimeout(0))
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
|
|||||||
}
|
}
|
||||||
return c.HTML(http.StatusOK, string(b))
|
return c.HTML(http.StatusOK, string(b))
|
||||||
})
|
})
|
||||||
|
srv.GET("/redirect-to-private", func(c echo.Context) error {
|
||||||
|
s.req = c.Request()
|
||||||
|
// Redirect the browser to a non-public address so the outbound filter
|
||||||
|
// is exercised on the redirected request rather than on this URL.
|
||||||
|
return c.Redirect(http.StatusFound, "http://127.0.0.1:9999/redirected")
|
||||||
|
})
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|||||||
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
18
test/integration/testdata/local-storage-html/index.html
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Local storage counter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Increments a per-origin localStorage counter and renders it, so a value
|
||||||
|
above 1 means a previous conversion of the same origin leaked into this
|
||||||
|
one. See https://github.com/gotenberg/gotenberg/issues/919. -->
|
||||||
|
<pre id="out"></pre>
|
||||||
|
<script>
|
||||||
|
var n = (parseInt(localStorage.getItem("n"), 10) || 0) + 1;
|
||||||
|
localStorage.setItem("n", n);
|
||||||
|
document.getElementById("out").textContent = "localStorageCount=" + n;
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
28
test/integration/testdata/screenshot-selector-html/index.html
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Element screenshot</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
/* A spacer pushes the target away from the origin so the clip has to
|
||||||
|
account for the element offset, not just its size. */
|
||||||
|
.spacer {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
#target {
|
||||||
|
width: 300px;
|
||||||
|
height: 200px;
|
||||||
|
background: #ff0000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div id="target"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
Binary file not shown.
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-eventsource-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>EventSource SSRF</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>EventSource SSRF</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// EventSource issues an ordinary HTTP GET, so it is surfaced as a
|
||||||
|
// Fetch.requestPaused event and blocked by the outbound filter.
|
||||||
|
new EventSource("http://127.0.0.1:9999/sse");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-fetch-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Fetch SSRF Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Fetch SSRF Control</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// Control: an ordinary fetch to a loopback address IS surfaced as a
|
||||||
|
// Fetch.requestPaused event and blocked by CHROMIUM_DENY_PRIVATE_IPS.
|
||||||
|
fetch("http://127.0.0.1:9999/ssrf-fetch").catch(() => {});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal file
20
test/integration/testdata/ssrf-websocket-html/index.html
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>WebSocket SSRF</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WebSocket SSRF</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// Both targets resolve to non-public addresses, so CHROMIUM_DENY_PRIVATE_IPS
|
||||||
|
// must block them. Unlike fetch/XHR/sub-resources, a WebSocket handshake is
|
||||||
|
// never surfaced as a Fetch.requestPaused event, so it currently escapes the
|
||||||
|
// outbound filter entirely.
|
||||||
|
// 127.0.0.1 -> loopback
|
||||||
|
// 169.254.169.254 -> link-local (cloud metadata)
|
||||||
|
new WebSocket("ws://127.0.0.1:9999/ssrf-websocket");
|
||||||
|
new WebSocket("ws://169.254.169.254:80/ssrf-websocket");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal file
15
test/integration/testdata/ssrf-websocket-tls-html/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>WSS SSRF</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WSS SSRF</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// wss:// (TLS) handshakes tunnel through the proxy via CONNECT, the same
|
||||||
|
// path as ws://, and must be filtered identically.
|
||||||
|
new WebSocket("wss://127.0.0.1:9999/wss-test");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal file
22
test/integration/testdata/ssrf-websocket-worker-html/index.html
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Worker WebSocket SSRF</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Worker WebSocket SSRF</h1>
|
||||||
|
<script type="application/javascript">
|
||||||
|
// A Web Worker is a separate CDP target, so its WebSocket handshake is
|
||||||
|
// not observed by listenForEventWebSocketCreated (which listens on the
|
||||||
|
// page target). Enforcement must therefore not rely on that listener:
|
||||||
|
// the pinning proxy sees every handshake regardless of the originating
|
||||||
|
// context and severs the connection.
|
||||||
|
const source =
|
||||||
|
'new WebSocket("ws://127.0.0.1:9999/ws-from-worker");' +
|
||||||
|
'new WebSocket("ws://169.254.169.254:80/ws-from-worker-metadata");';
|
||||||
|
const blob = new Blob([source], { type: "application/javascript" });
|
||||||
|
new Worker(URL.createObjectURL(blob));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
43
test/integration/testdata/wide-table-html/index.html
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Wide table</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 8px 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- A single wide row: much wider than tall, so a correct single page is
|
||||||
|
landscape, and the rightmost column only survives if the width was
|
||||||
|
expanded to the content instead of truncated. -->
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>Column 1</td>
|
||||||
|
<td>Column 2</td>
|
||||||
|
<td>Column 3</td>
|
||||||
|
<td>Column 4</td>
|
||||||
|
<td>Column 5</td>
|
||||||
|
<td>Column 6</td>
|
||||||
|
<td>Column 7</td>
|
||||||
|
<td>Column 8</td>
|
||||||
|
<td>Column 9</td>
|
||||||
|
<td>Column 10</td>
|
||||||
|
<td>Column 11</td>
|
||||||
|
<td>Column 12</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user