Compare commits

...

25 Commits

Author SHA1 Message Date
Julien Neuhart
fe1b0020b8 chore(deps): update golang to 1.26.2 2026-04-30 15:24:06 +02:00
Julien Neuhart
b47b9f45d8 docs(contributing): reorganize sections and slim PR checklist 2026-04-30 15:14:03 +02:00
Julien Neuhart
8c0ad887f2 chore(deps): update pdfcpu to v0.12.0 (#1537) 2026-04-30 14:51:05 +02:00
Julien Neuhart
45b9f32351 refactor(chromium): drop paint-callback polyfill now that chromedp is pinned 2026-04-30 14:16:59 +02:00
Julien Neuhart
ace379a92c fix(deps): pin chromedp to v0.14.2 to restore print-mode paint pipeline 2026-04-30 14:16:59 +02:00
Julien Neuhart
4998870723 feat(libreoffice): SSRF guard for embedded external content 2026-04-30 14:16:59 +02:00
Julien Neuhart
64c28dd45e fix(supervisor): retry first launch on failure (#1538) 2026-04-30 14:16:59 +02:00
Julien Neuhart
68e0f88d5b refactor(exiftool): switch from go-exiftool library to direct CLI 2026-04-24 14:40:39 +02:00
Julien Neuhart
cc97cb7e59 docs(chromium): tighten paint-polyfill always-on godoc per CONTRIBUTING style 2026-04-24 14:33:02 +02:00
Julien Neuhart
200334197f feat(chromium): always inject paint-callback polyfill to cover waitDelay users 2026-04-24 14:30:45 +02:00
Julien Neuhart
ce9c48b2b0 fix(testdata): prettify 2026-04-24 12:52:57 +02:00
Julien Neuhart
05465b3a74 docs(chromium): tighten paint-polyfill godoc per CONTRIBUTING style [skip ci] 2026-04-24 12:51:09 +02:00
Julien Neuhart
1c0ff24c4b feat(chromium): inject paint-callback polyfill when waitForExpression or waitForSelector is set 2026-04-24 12:14:52 +02:00
Julien Neuhart
8f711b0f99 Revert "feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set"
This reverts commit 430f95f79f.
2026-04-24 11:19:14 +02:00
Julien Neuhart
430f95f79f feat(chromium): skip lifecycle waits when waitForExpression or waitForSelector is set 2026-04-24 09:28:27 +02:00
Julien Neuhart
259d80bb68 fix(test): deny-private-ips on scenario now uses http://127.0.0.1/ as the URL 2026-04-23 21:30:43 +02:00
Julien Neuhart
7a914fce65 fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults 2026-04-23 20:01:27 +02:00
Julien Neuhart
a2a8c42457 fix(chromium): default-deny file:// sub-resources when no prefix is allowed 2026-04-22 07:47:19 +02:00
Julien Neuhart
4b192b1498 fix(webhook): detach async goroutine from pooled echo.Context 2026-04-22 07:47:19 +02:00
Julien Neuhart
c204cadfc5 fix(pdfengines): require uploaded stamp/watermark file for image or pdf source 2026-04-22 07:47:19 +02:00
Julien Neuhart
35f1a990a6 fix(chromium): harden outbound URL handling 2026-04-22 07:47:19 +02:00
Pieter Oliver
7729bd0590 chore(npm): restrict npm installs + package bumps for a week (try to prevent 0-days from upstream dependency changes) 2026-04-21 20:22:09 +02:00
dependabot[bot]
2980ca97a3 chore(deps): bump github.com/labstack/gommon from 0.4.2 to 0.5.0
Bumps [github.com/labstack/gommon](https://github.com/labstack/gommon) from 0.4.2 to 0.5.0.
- [Release notes](https://github.com/labstack/gommon/releases)
- [Commits](https://github.com/labstack/gommon/compare/v0.4.2...v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/gommon
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:58 +02:00
dependabot[bot]
bb95b9b7f3 chore(deps): bump github.com/dlclark/regexp2 from 1.11.5 to 1.12.0
Bumps [github.com/dlclark/regexp2](https://github.com/dlclark/regexp2) from 1.11.5 to 1.12.0.
- [Commits](https://github.com/dlclark/regexp2/compare/v1.11.5...v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/dlclark/regexp2
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:34 +02:00
dependabot[bot]
62d98fef79 chore(deps-dev): bump prettier from 3.8.2 to 3.8.3
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 12:47:18 +02:00
50 changed files with 3272 additions and 515 deletions

View File

@@ -16,3 +16,5 @@ updates:
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

View File

@@ -46,7 +46,7 @@ jobs:
node-version-file: .node-version
- name: Install Dependencies
run: npm i
run: npm ci --ignore-scripts
- name: Run linters
run: make lint-prettier

View File

@@ -1 +1 @@
24.11.0
24.15.0

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=7

View File

@@ -1,6 +1,11 @@
# Contributing to Gotenberg
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else: **backward compatibility** (never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion) and **defensive programming** (assume input is malformed, handle errors explicitly, never panic).
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
- **Backward compatibility.** Never rename or remove CLI flags, environment variables, API form fields, or HTTP endpoints without discussion.
- **Defensive programming.** Assume input is malformed, handle errors explicitly, never panic.
## Toolchain
- Module: `github.com/gotenberg/gotenberg/v8`
- Go: see version in `go.mod`
@@ -8,38 +13,13 @@ Gotenberg is a Docker-based API for converting documents to PDF. Two rules overr
- Node.js (see `.node-version`), for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
## Quick start
## Before you start
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
make fmt # Format Go code
make prettify # Format non-Go files (Markdown, YAML, etc.)
make lint # Lint Go code (zero errors permitted)
make lint-prettier # Lint non-Go files
make test-unit # Run unit tests
make build # Required before integration tests
make test-integration # Run all integration tests
make telemetry # Start OpenTelemetry collector and OpenObserve
make down # Stop all compose containers
```
For non-trivial changes, open an issue or a draft PR first. Describe what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
Run only the integration tests relevant to your change:
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ---------------- | --------------------------------------------- | ------------------------------------------------------------------------ |
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
When adding a feature or route, write the Gherkin scenario before the Go code, and plan to update the Bruno collection (`.bruno/`) if a route changes.
## Project layout
@@ -53,9 +33,35 @@ build/ -> Dockerfile, fonts, Chromium config.
.bruno/ -> Bruno API collection (mirrors every route).
```
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers.
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
## Coding rules
## Setup and Makefile
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
| `make build` | Build the Gotenberg Docker image | Before integration tests or manual testing |
| `make run` | Run a Gotenberg container via `docker compose` | Manual testing. Flags configured via Makefile variables and compose.yaml |
| `make telemetry` | Start an OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
| `make fmt` | Format Go code | Before committing |
| `make lint` | Lint Go code (zero errors permitted) | Before committing |
| `make prettify` | Format non-Go files (Markdown, YAML, JSON) | Before committing |
| `make lint-prettier` | Lint non-Go files | Before committing |
| `make test-unit` | Run unit tests | Before committing |
| `make test-integration` | Run all integration tests (40 min timeout) | Before committing |
Run only the integration test tag(s) relevant to your change rather than the full suite:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Code conventions
### Module system
@@ -63,17 +69,21 @@ Gotenberg uses a self-registering module architecture inspired by CaddyServer. E
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
### Backward compatibility
CLI flags, environment variables, API form fields, HTTP endpoints, and default values that alter existing behavior must not change without discussion. Deprecate old names with `fs.MarkDeprecated()` and register both the old and new names side by side.
If a change violates backward compatibility, flag it as a breaking change in the PR description.
### Error handling
- Wrap every error with context: `fmt.Errorf("description: %w", err)`.
- Never swallow errors silently.
- Match errors with `errors.Is`, never `strings.Contains`.
- No panics in production code paths.
- Input is validated defensively.
### Import ordering
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- Validate input defensively.
### Logging
@@ -83,15 +93,11 @@ Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`
External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics.
### No business logic in `cmd/`
### Import ordering
The `cmd/gotenberg/` package is strictly for wiring and startup.
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
### Mocks
Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests rather than creating new ones.
## Documentation rules
## Documentation conventions
### Tone
@@ -131,60 +137,42 @@ Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
- Mark debt with `// TODO: [context]`.
### Formatting non-Go files
Run `make prettify && make lint-prettier` for YAML, Markdown, and JSON.
## Testing
Integration tests use Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`. Step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` to understand step definitions before writing new tests.
### Unit tests
Unit tests: table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
Run only the relevant integration test tag rather than the full suite (40min timeout).
### Integration tests
See:
Gherkin (BDD) via Godog with `testcontainers-go` for Docker orchestration. Feature files live in `test/integration/features/`; step definitions live in `test/integration/scenario/`. Read `scenario.go` and `containers.go` before writing new tests.
- [`test/integration/README.md`](test/integration/README.md): Gherkin step reference, available tags, writing new tests.
- [`.bruno/README.md`](.bruno/README.md): `.bru` file format, conventions, route update checklist.
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md): adding new engine features (Makefile variable and flag).
`make build` is required before running integration tests. The full suite has a 40-minute timeout, so run only the tag(s) relevant to your change.
## Pull requests
Plan non-trivial changes before coding. Open an issue or draft PR describing what needs to change, the proposed solution (files to modify, interface changes, form fields), and which integration test tags are affected.
### Guidelines
- One thing per PR. Keep features, bug fixes, and refactoring separate.
- Backward compatibility matters. Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
- Integration tests first. When adding a feature or route, start by writing the Gherkin scenario.
- Bruno collection must be updated if routes were added or modified.
### Checklist
- [ ] No existing CLI flags renamed or removed
- [ ] No existing environment variables renamed or removed
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
- [ ] Errors wrapped with context: `fmt.Errorf("description: %w", err)`
- [ ] No business logic in `cmd/`
- [ ] No panics in production code paths
- [ ] New features belong in the correct module (or justify a new one)
- [ ] Linting: `make fmt && make lint && make prettify && make lint-prettier` passes with zero warnings
- [ ] Every exported function, type, constant, and variable has a Godoc comment starting with its name
- [ ] New packages include a `doc.go` file
- [ ] Integration tests pass: `make test-integration` (at minimum, the relevant tags)
- [ ] Unit tests pass: `make test-unit`
- [ ] Bruno collection updated (if routes were added or modified)
If any backward compatibility item is violated, the change **must** be flagged as a breaking change.
### Commits
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope matches the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Stage specific files. Never `git add -A` or `git add .`.
### Checklist
Before opening the PR, confirm:
- [ ] No backward-compatibility regression. See [Backward compatibility](#backward-compatibility).
- [ ] Code conventions met (error wrapping, logging, telemetry, import ordering, no panics, no business logic in `cmd/`). See [Code conventions](#code-conventions).
- [ ] Documentation conventions met (Godoc on every exported identifier, `doc.go` for new packages, tone). See [Documentation conventions](#documentation-conventions).
- [ ] `make fmt && make lint && make prettify && make lint-prettier` pass with zero warnings.
- [ ] `make test-unit` passes.
- [ ] Relevant `make test-integration TAGS=...` passes.
- [ ] Bruno collection updated if routes were added or modified.
## Further reading
- [`test/integration/README.md`](test/integration/README.md) — Gherkin step reference, available tags, writing new tests.
- [`.bruno/README.md`](.bruno/README.md) — `.bru` file format, conventions, route update checklist.
- [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) — adding new engine features (Makefile variable and flag).

View File

@@ -58,6 +58,8 @@ LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FIELDS_PREFIX=

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.26.0
ARG GOLANG_VERSION=1.26.2
# ----------------------------------------------
# pdfcpu binary build stage
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.0
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.11.1
ARG PDFCPU_VERSION=v0.12.0
ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules).
@@ -24,7 +24,7 @@ RUN curl -Ls "https://github.com/pdfcpu/pdfcpu/archive/refs/tags/$PDFCPU_VERSION
RUN go mod download \
&& go mod verify
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
RUN go build -o pdfcpu -ldflags "-s -w -X 'main.version=$PDFCPU_VERSION' -X 'github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model.VersionStr=$PDFCPU_VERSION' -X main.builtBy=gotenberg" ./cmd/pdfcpu \
# Verify installation.
&& ./pdfcpu version

View File

@@ -59,6 +59,8 @@ services:
- "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"

11
go.mod
View File

@@ -1,19 +1,18 @@
module github.com/gotenberg/gotenberg/v8
go 1.26.0
go 1.26.2
require (
github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a
github.com/chromedp/chromedp v0.15.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/cucumber/godog v0.15.1
github.com/dlclark/regexp2 v1.11.5
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260412113850-134a5b2cce7f
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/gommon v0.4.2
github.com/labstack/gommon v0.5.0
github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27
github.com/moby/moby/api v1.54.1

19
go.sum
View File

@@ -14,8 +14,6 @@ github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eT
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs=
github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
@@ -30,10 +28,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a h1:Kk4P1W58eAf+OUGtx51cM7CcJokJuBEmOxxwPdHFH4Q=
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -61,8 +59,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -148,8 +146,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
@@ -233,7 +231,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=

8
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.2",
"prettier": "3.8.3",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -58,9 +58,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz",
"integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.8.2",
"prettier": "3.8.3",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}

View File

@@ -55,6 +55,13 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar
}, nil
}
// SetEnv replaces the environment variables passed to the underlying
// process. When SetEnv is not called, the process inherits the parent's
// environment.
func (cmd *Cmd) SetEnv(env []string) {
cmd.process.Env = env
}
// Start starts the command but does not wait for its completion.
func (cmd *Cmd) Start() error {
err := cmd.pipeOutput()

View File

@@ -21,14 +21,20 @@ import (
// example [::ffff:127.0.0.1]).
var ErrNonPublicIP = errors.New("non-public IP")
// netipResolver is the subset of [net.Resolver] used by
// [ResolveAndCheckPublic]. Defining it as an interface allows tests to
// substitute a stub resolver.
// ErrPublicIP indicates that an outbound URL targets an IP address that is
// reachable on the public internet. It is returned when a caller opts
// into denying public destinations via [WithDenyPublicIPs]; typical use
// cases are air-gapped or data-governed deployments where Gotenberg must
// only talk to hosts on a private network.
var ErrPublicIP = errors.New("public IP")
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
// Defining it as an interface allows tests to substitute a stub resolver.
type netipResolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
// outboundResolver is the resolver used by [ResolveAndCheckPublic]. It is a
// outboundResolver is the resolver used by [resolveHost]. It is a
// package-level variable so that tests can substitute a stub resolver.
var outboundResolver netipResolver = net.DefaultResolver
@@ -62,18 +68,40 @@ func IsPublicIP(addr netip.Addr) bool {
return true
}
// ResolveAndCheckPublic resolves host and returns the resolved addresses,
// or an error if any resolved address fails [IsPublicIP]. If host is itself
// an IP literal, it is checked directly without performing a DNS lookup.
// The returned slice can be used to pin a subsequent dial to a specific IP
// and prevent DNS rebinding between this validation and the connect.
// ResolveAndCheckPublic resolves host and rejects any resolved address
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
// bare host. Callers that need a different policy should use
// [DecideOutbound] directly.
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
return resolveHost(ctx, host, true, false)
}
// resolveHost resolves host and returns the addresses. When denyPrivate
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
// denyPublic is true, a public address is rejected with [ErrPublicIP].
// Both checks may be active at the same time, in which case any
// resolved address fails and the caller must rely on an allow-list
// bypass.
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
if host == "" {
return nil, errors.New("empty host")
}
check := func(a netip.Addr) error {
public := IsPublicIP(a)
if denyPublic && public {
return fmt.Errorf("%q: %w", a, ErrPublicIP)
}
if denyPrivate && !public {
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
}
return nil
}
if addr, err := netip.ParseAddr(host); err == nil {
if !IsPublicIP(addr) {
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
if err := check(addr); err != nil {
return nil, err
}
return []netip.Addr{addr}, nil
}
@@ -85,36 +113,72 @@ func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, erro
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
if !IsPublicIP(a) {
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
if err := check(a); err != nil {
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
}
}
return addrs, nil
}
// outboundDecision is the result of validating an outbound URL. It is
// stashed in the request context by [outboundRoundTripper] so that
// [secureDialContext] can either bypass the IP check (allow-list match) or
// pin the dial to the IPs that were resolved at validation time.
type outboundDecision struct {
// bypass is true when an allow-list pattern matched the URL. In that
// case the operator has explicitly opted into the destination and the
// dial should proceed without an IP check.
bypass bool
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. Callers use it to dial the destination either directly
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
// that the connect targets the IPs resolved at validation time. Passing
// the decision to the dialer closes the window between validation and
// connect that DNS rebinding exploits.
type OutboundDecision struct {
// Bypass is true when an allow-list pattern matched the URL. The
// operator has explicitly opted into the destination; the caller
// should dial directly without an additional IP check.
Bypass bool
// pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
// host. The dial should be pinned to one of these to prevent DNS
// rebinding between validation and connect.
pinned []netip.Addr
// Pinned holds the IPs resolved for the URL host. The caller should
// dial one of these via [DialPinned] to prevent DNS rebinding between
// validation and connect.
Pinned []netip.Addr
}
// outboundDecisionKey is the context key under which an [outboundDecision]
// outboundDecisionKey is the context key under which an [OutboundDecision]
// is stored.
type outboundDecisionKey struct{}
// decideConfig carries optional settings for [DecideOutbound] and
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
type decideConfig struct {
denyPrivateIPs bool
denyPublicIPs bool
}
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
// validate a URL. Options are applied in order on top of the permissive
// defaults (no IP-class rejection).
type DecideOption func(*decideConfig)
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
// address (loopback, RFC1918, link-local, unique-local, multicast,
// unspecified). DNS still runs and the returned [OutboundDecision] still
// carries the resolved IPs for dial pinning, so enabling or disabling
// this option does not affect DNS-rebinding protection. Use it on
// internet-exposed deployments to mitigate SSRF against internal
// services.
func WithDenyPrivateIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPrivateIPs = deny }
}
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
// address. Use it on air-gapped or data-governed deployments where
// Gotenberg must only reach hosts on a private network; the option
// prevents data exfiltration to attacker-controlled public servers via
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
// address fails and only an allow-list bypass permits a destination.
func WithDenyPublicIPs(deny bool) DecideOption {
return func(c *decideConfig) { c.denyPublicIPs = deny }
}
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
// Only these schemes go through the IP-based public-address check; data,
// blob, file, and other schemes are filtered by the regex layer alone.
// Only these schemes go through the IP-based address check; data, blob,
// file, and other schemes are filtered by the regex layer alone.
func httpLikeScheme(scheme string) bool {
switch scheme {
case "http", "https", "ws", "wss":
@@ -123,14 +187,38 @@ func httpLikeScheme(scheme string) bool {
return false
}
// decideOutbound parses rawURL, runs the regex allow/deny lists against the
// normalized form, and (when no allow-list match) resolves the host and
// rejects any non-public address. It returns the resulting
// [outboundDecision] which the caller can stash in a context for the dial.
func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) {
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
// the normalized form, and (when no allow-list match) resolves the host
// and applies the IP-class checks selected by opts. It returns the
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
// that were resolved here and skip a second DNS lookup later, which
// closes the DNS rebinding window that affects callers that only receive
// an error from [FilterOutboundURL].
//
// The semantics:
//
// 1. The URL is parsed and its scheme and host lowercased.
// 2. allowList and denyList apply against the normalized form with OR
// semantics. The deny-list always applies.
// 3. For http, https, ws, and wss, the host is resolved and every
// resolved address must satisfy the enabled IP-class checks
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
// bypasses the IP-class checks and the returned decision carries
// Bypass true. Otherwise the decision carries Pinned with the
// resolved addresses.
//
// Callers that dial the destination themselves must honor Bypass and
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
// pinned URLs must dial one of Pinned via [DialPinned].
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
cfg := decideConfig{}
for _, opt := range opts {
opt(&cfg)
}
parsed, err := url.Parse(rawURL)
if err != nil {
return outboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
@@ -145,9 +233,9 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return outboundDecision{}, context.DeadlineExceeded
return OutboundDecision{}, context.DeadlineExceeded
}
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
@@ -157,7 +245,7 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
}
if !allowMatched {
return outboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
}
}
@@ -168,77 +256,69 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return outboundDecision{}, context.DeadlineExceeded
return OutboundDecision{}, context.DeadlineExceeded
}
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
return outboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
}
}
if allowMatched {
return outboundDecision{bypass: true}, nil
return OutboundDecision{Bypass: true}, nil
}
if !httpLikeScheme(parsed.Scheme) {
return outboundDecision{}, nil
return OutboundDecision{}, nil
}
host := parsed.Hostname()
if host == "" {
return outboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
}
addrs, err := ResolveAndCheckPublic(ctx, host)
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
if err != nil {
if errors.Is(err, ErrNonPublicIP) {
return outboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
switch {
case errors.Is(err, ErrNonPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
case errors.Is(err, ErrPublicIP):
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
default:
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
}
return outboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
}
return outboundDecision{pinned: addrs}, nil
return OutboundDecision{Pinned: addrs}, nil
}
// FilterOutboundURL validates that rawURL is acceptable for an outbound
// request from Gotenberg. It is the URL-aware replacement for
// [FilterDeadline] and should be preferred for any new code that filters a
// URL before issuing or instructing an outbound request.
// [FilterDeadline] and should be preferred for any new code that filters
// a URL before issuing or instructing an outbound request.
//
// The function:
//
// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This
// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading
// case-sensitive deny-list regexes.
// 2. Applies allowList and denyList against the normalized form using the
// same OR semantics as [FilterDeadline].
// 3. When no allow-list entry explicitly matched and the scheme is one of
// http, https, ws, or wss, resolves the host and verifies every
// resolved address with [IsPublicIP]. This blocks loopback, private,
// link-local, and other internal targets even when the regex layer
// does not cover the textual form (for example IPv4-mapped IPv6 like
// [::ffff:127.0.0.1], or hostnames that resolve to a private address).
//
// An allow-list match bypasses the IP check, allowing operators to opt
// into specific internal destinations via --*-allow-list flags. The
// deny-list always applies and cannot be bypassed by an allow-list match.
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) error {
_, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline)
// The default behavior is permissive: the URL passes as long as it clears
// the regex allow-list and deny-list. Callers that need IP-class checks
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
// always applies and cannot be bypassed by an allow-list match.
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
return err
}
// outboundRoundTripper is an [http.RoundTripper] that validates each request
// URL via [decideOutbound] and stashes the resulting [outboundDecision] in
// the request context so that [secureDialContext] can pin the dial or
// bypass the IP check as appropriate. Because the http.Client invokes
// RoundTrip again for each redirect hop, this also re-validates redirect
// targets without a separate CheckRedirect.
// outboundRoundTripper is an [http.RoundTripper] that validates each
// request URL via [DecideOutbound] and stashes the resulting
// [OutboundDecision] in the request context so that [secureDialContext]
// can pin the dial or bypass the IP check as appropriate. Because the
// http.Client invokes RoundTrip again for each redirect hop, this also
// re-validates redirect targets without a separate CheckRedirect.
type outboundRoundTripper struct {
base http.RoundTripper
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
opts []DecideOption
}
// RoundTrip validates req.URL and delegates to the base transport.
@@ -248,7 +328,7 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
deadline = time.Now().Add(30 * time.Second)
}
decision, err := decideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
if err != nil {
return nil, err
}
@@ -258,15 +338,17 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
}
// NewOutboundHttpClient returns an [http.Client] that validates every
// outbound request URL via the same logic as [FilterOutboundURL] and pins
// the resulting dial to a resolved public IP. An allow-list match
// (operator opt-in to a specific destination) bypasses the IP check.
// outbound request URL via the same logic as [FilterOutboundURL] and
// pins the resulting dial to the resolved IPs.
//
// The client re-validates redirect targets automatically because the
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
// per hop. This closes the redirect-based SSRF bypass that affects raw
// [http.Client] usage when no CheckRedirect is set.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp) *http.Client {
//
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone()
base.DialContext = secureDialContext
return &http.Client{
@@ -275,41 +357,47 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
base: base,
allowList: allowList,
denyList: denyList,
opts: opts,
},
}
}
// secureDialContext consumes the [outboundDecision] stashed in ctx by
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
// [outboundRoundTripper]. When the decision is to bypass (allow-list
// match), it dials directly. When the decision contains pinned IPs, it
// dials each in turn until one connects. When no decision is present (the
// dialer was used outside of [outboundRoundTripper]), it falls back to
// resolving and checking the destination itself.
// dials each in turn until one connects. When no decision is present
// (the dialer was used outside of [outboundRoundTripper]), it falls back
// to resolving the destination without IP-class checks so that the
// fallback matches the permissive default and operators who need
// restrictions configure them at the caller.
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
}
if decision, ok := ctx.Value(outboundDecisionKey{}).(outboundDecision); ok {
if decision.bypass {
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
if decision.Bypass {
return outboundDialer.DialContext(ctx, network, addr)
}
if len(decision.pinned) > 0 {
return dialPinned(ctx, network, decision.pinned, port)
if len(decision.Pinned) > 0 {
return DialPinned(ctx, network, decision.Pinned, port)
}
}
addrs, err := ResolveAndCheckPublic(ctx, host)
addrs, err := resolveHost(ctx, host, false, false)
if err != nil {
return nil, err
}
return dialPinned(ctx, network, addrs, port)
return DialPinned(ctx, network, addrs, port)
}
// dialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error.
func dialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. Callers pass the Pinned
// slice from [OutboundDecision] so that the dial targets exactly the IPs
// that [DecideOutbound] resolved, preventing DNS rebinding between
// validation and connect.
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
var lastErr error
for _, a := range addrs {
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))

View File

@@ -106,6 +106,7 @@ func TestFilterOutboundURL(t *testing.T) {
rawURL string
allow []*regexp2.Regexp
deny []*regexp2.Regexp
opts []DecideOption
stub func(host string) ([]netip.Addr, error)
expectErr bool
expectIs error
@@ -135,6 +136,7 @@ func TestFilterOutboundURL(t *testing.T) {
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
@@ -142,28 +144,32 @@ func TestFilterOutboundURL(t *testing.T) {
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
rawURL: "http://[::ffff:10.0.0.1]/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to public IP passes",
scenario: "hostname resolving to public IP passes with deny-private-ips",
rawURL: "https://example.com/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "hostname resolving to loopback blocked",
scenario: "hostname resolving to loopback blocked with deny-private-ips",
rawURL: "https://rebind.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "hostname resolving to mixed public+private blocked",
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
rawURL: "https://mixed.example/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
expectErr: true,
expectIs: ErrFiltered,
@@ -173,7 +179,7 @@ func TestFilterOutboundURL(t *testing.T) {
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
deny: defaultDeny,
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "10.0.0.1"), nil },
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: false,
},
{
@@ -205,23 +211,25 @@ func TestFilterOutboundURL(t *testing.T) {
expectIs: ErrFiltered,
},
{
scenario: "Issue 1: Chromium default does not block http to public host (regex layer)",
scenario: "Chromium default permissive passes http to public host",
rawURL: "https://example.com/",
deny: chromiumDeny,
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
expectErr: false,
},
{
scenario: "Issue 1: Chromium default now blocks http to loopback via IP layer",
scenario: "Chromium with deny-private-ips blocks http to loopback",
rawURL: "http://127.0.0.1:3000/health",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 1: Chromium default now blocks cloud metadata via IP layer",
scenario: "Chromium with deny-private-ips blocks cloud metadata",
rawURL: "http://169.254.169.254/latest/meta-data/",
deny: chromiumDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
@@ -237,9 +245,10 @@ func TestFilterOutboundURL(t *testing.T) {
expectIs: ErrFiltered,
},
{
scenario: "userinfo cannot mask host",
scenario: "userinfo cannot mask host when deny-private-ips enabled",
rawURL: "http://example.com@127.0.0.1/",
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: true,
expectIs: ErrFiltered,
},
@@ -255,7 +264,7 @@ func TestFilterOutboundURL(t *testing.T) {
})
}
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second))
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
if tc.expectErr && err == nil {
t.Fatalf("expected error, got nil")
@@ -306,3 +315,143 @@ func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
}
}
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := DecideOutbound(
context.Background(),
"http://127.0.0.1:8080/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
}
}
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "93.184.216.34"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if err != nil {
t.Fatalf("expected no error for public host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
}
}
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
}
}
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPublicIPs(true),
)
if err != nil {
t.Fatalf("expected no error for private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
// Both denies active and no allow-list match: every resolved address
// fails. Only an allow-list match can permit a destination under
// this posture.
_, err := DecideOutbound(
context.Background(),
"http://example.com/",
nil, nil,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
WithDenyPublicIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
}
}
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
// The regex deny-list fires before any resolution; verifies that
// operator-supplied deny patterns remain effective regardless of
// IP-class options.
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
_, err := DecideOutbound(
context.Background(),
"http://evil.local/",
nil, deny,
time.Now().Add(5*time.Second),
WithDenyPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("deny-list must still reject, got: %v", err)
}
}
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "10.0.0.5"), nil
})
// No options passed: default posture is permissive across both
// IP classes. The caller still gets pinned IPs for dial safety.
decision, err := DecideOutbound(
context.Background(),
"http://internal.svc/",
nil, nil,
time.Now().Add(5*time.Second),
)
if err != nil {
t.Fatalf("permissive default must allow private host, got: %v", err)
}
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
}
}

View File

@@ -86,12 +86,13 @@ type processSupervisor struct {
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
// firstStartErr stores the error from the first Launch attempt executed
// via firstStartOnce. Subsequent callers that enter the !firstStart block
// need to observe this value after the Once has completed, without
// re-executing the closure.
firstStartErr error
// firstStartMu serializes lazy-launch attempts so concurrent callers do
// not all spawn Launch() simultaneously. Using a mutex (instead of
// sync.Once) lets a failed launch be retried by the next caller, since a
// transient failure (such as a cold-start timeout) must not poison the
// supervisor for the rest of the container's lifetime. See
// https://github.com/gotenberg/gotenberg/issues/1538.
firstStartMu sync.Mutex
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
@@ -346,8 +347,6 @@ func (s *processSupervisor) maybeIdleShutdown() {
// Reset state so ensureStarted() re-launches on next request.
s.firstStart.Store(false)
s.firstStartOnce = sync.Once{}
s.firstStartErr = nil
s.reqCounter.Store(0)
s.logger.DebugContext(context.Background(), "process stopped due to idle timeout")
@@ -375,21 +374,27 @@ func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger
}
}
// ensureStarted performs a one-time lazy launch of the process on its first
// use. Subsequent calls are no-ops.
// ensureStarted performs a lazy launch of the process on its first use.
// Concurrent callers serialize on firstStartMu; once the launch succeeds,
// subsequent calls short-circuit on the firstStart flag. A failed launch
// leaves firstStart unset, so the next caller retries the launch.
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
if s.firstStart.Load() {
return nil
}
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
s.firstStartMu.Lock()
defer s.firstStartMu.Unlock()
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
if s.firstStart.Load() {
return nil
}
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
}
return nil

View File

@@ -898,6 +898,53 @@ func TestProcessSupervisor_IdleShutdown(t *testing.T) {
}
}
func TestProcessSupervisor_RetryAfterFailedFirstStart(t *testing.T) {
// Regression test for https://github.com/gotenberg/gotenberg/issues/1538:
// a failed first launch must not poison the supervisor; the next request
// must retry Launch() instead of returning the cached error forever.
logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
if startCalls.Add(1) == 1 {
return errors.New("first start failed")
}
return nil
},
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := ps.Run(ctx, logger, func() error { return nil })
if err == nil {
t.Fatal("expected first Run to fail because Launch failed")
}
if ps.firstStart.Load() {
t.Fatal("firstStart must remain false after a failed Launch")
}
err = ps.Run(ctx, logger, func() error { return nil })
if err != nil {
t.Fatalf("expected second Run to succeed after the supervisor retries Launch, got: %v", err)
}
if !ps.firstStart.Load() {
t.Fatal("expected firstStart to be set after the second Launch succeeds")
}
if got := startCalls.Load(); got != 2 {
t.Fatalf("expected exactly 2 Start calls, got %d", got)
}
}
func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
logger := slog.New(slog.DiscardHandler)

View File

@@ -57,10 +57,12 @@ type Api struct {
}
type downloadFromConfig struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
maxRetry int
disable bool
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
maxRetry int
disable bool
}
// Router is a module interface that adds routes to the [Api].
@@ -196,7 +198,9 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services")
fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
@@ -235,10 +239,12 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.rootPath = flags.MustString("api-root-path")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")

View File

@@ -232,7 +232,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline)
ipOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs),
}
err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -268,7 +272,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}
client := &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList),
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),

View File

@@ -337,7 +337,10 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*slog.Logger)
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// We create a context with a timeout so that underlying processes are
// able to stop early and correctly handle a timeout scenario.
@@ -395,7 +398,14 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*slog.Logger)
// Guard the type assertion so a pooled [echo.Context] whose
// store has been recycled under us does not crash the process.
// See the webhook async handler for the race this protects
// against.
logger, _ := c.Get("logger").(*slog.Logger)
if logger == nil {
return errors.New("no logger in context (possible pool reuse)")
}
// Define a hard timeout if the route handler fails to timeout as
// expected.

View File

@@ -0,0 +1,39 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
)
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
mw := hardTimeoutMiddleware(100 * time.Millisecond)
handler := mw(func(c echo.Context) error { return nil })
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// c has no "logger" key, mimicking a pooled context whose store was
// recycled under a concurrently running webhook goroutine. The
// middleware must surface an error instead of panicking on the
// unchecked type assertion the pre-fix code relied on.
defer func() {
if r := recover(); r != nil {
t.Fatalf("hardTimeoutMiddleware panicked: %v", r)
}
}()
err := handler(c)
if err == nil {
t.Fatal("expected an error for missing logger, got nil")
}
if !strings.Contains(err.Error(), "logger") {
t.Fatalf("error = %q, want a message mentioning logger", err)
}
}

View File

@@ -44,6 +44,8 @@ type browserArguments struct {
// Tasks specific.
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
clearCache bool
clearCookies bool
disableJavaScript bool
@@ -57,15 +59,17 @@ type chromiumBrowser struct {
ctxMu sync.RWMutex
isStarted atomic.Bool
arguments browserArguments
fs *gotenberg.FileSystem
arguments browserArguments
fs *gotenberg.FileSystem
pinningProxy *pinningProxy
}
func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
initialCtx: context.Background(),
arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs),
}
b.isStarted.Store(false)
@@ -136,6 +140,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
}
// Default: route Chromium through the internal pinning proxy so that
// Chromium never performs its own DNS lookup for the navigation URL
// or any sub-resource. The proxy resolves and validates each URL
// once per request and dials the pinned IP, closing the DNS
// rebinding window between Gotenberg's validation and Chromium's
// connect.
//
// Skip when the operator has configured their own egress proxy or
// custom host-resolver mappings: those deployments take
// responsibility for outbound safety themselves and routing through
// an internal proxy would override their configuration.
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
err = b.pinningProxy.Start(logger)
if err != nil {
return fmt.Errorf("start pinning proxy: %w", err)
}
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
}
// See https://github.com/gotenberg/gotenberg/issues/524.
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
@@ -236,6 +259,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
b.userProfileDirPath = ""
b.isStarted.Store(false)
// Stop the pinning proxy after Chromium shutdown so that any
// in-flight requests Chromium issues during teardown complete. The
// Stop call is a no-op when the proxy was not started (operator
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
err := b.pinningProxy.Stop(logger)
if err != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
}
return nil
}
@@ -338,7 +370,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
// We validate the "main" URL against our allowed / deny lists, and
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline)
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline,
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -359,6 +394,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
denyPrivateIPs: b.arguments.denyPrivateIPs,
denyPublicIPs: b.arguments.denyPublicIPs,
allowedFilePrefixes: options.AllowedFilePrefixes,
extraHttpHeaders: options.ExtraHttpHeaders,
})

View File

@@ -189,10 +189,13 @@ type Options struct {
// PDFs with transparency.
OmitBackground bool
// AllowedFilePrefixes restricts file:// sub-resource access to only these
// directory prefixes. Applied in listenForEventRequestPaused in addition
// to the global allow/deny lists. Set internally by route handlers, not
// via form data.
// AllowedFilePrefixes restricts file:// sub-resource access to only
// these directory prefixes. Applied in listenForEventRequestPaused in
// addition to the global allow/deny lists. An empty slice
// default-denies every file:// sub-resource, so routes that legitimately
// render local files (HTML, Markdown) must populate this with the
// request working directory while routes that navigate remote URLs
// leave it empty. Set internally by route handlers, not via form data.
AllowedFilePrefixes []string
}
@@ -447,6 +450,8 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services")
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-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
@@ -495,6 +500,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
allowList: flags.MustRegexpSlice("chromium-allow-list"),
denyList: flags.MustRegexpSlice("chromium-deny-list"),
denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"),
denyPublicIPs: flags.MustBool("chromium-deny-public-ips"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),

View File

@@ -25,6 +25,8 @@ import (
type eventRequestPausedOptions struct {
allowList, denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
allowedFilePrefixes []string
extraHttpHeaders []ExtraHttpHeader
}
@@ -52,29 +54,27 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
return
}
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline)
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline,
gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(options.denyPublicIPs),
)
if err != nil {
logger.WarnContext(ctx, err.Error())
allow = false
}
// Additional restriction: if the sub-resource is a file:// URL
// and we have allowed file prefixes, restrict access to only
// those directories. This prevents cross-request file access
// in /tmp.
if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 {
prefixMatch := false
for _, prefix := range options.allowedFilePrefixes {
if strings.HasPrefix(e.Request.URL, "file://"+prefix) {
prefixMatch = true
break
}
}
if !prefixMatch {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
// Sub-resource file:// URLs are opt-in per route. A route
// that renders local files (HTML, Markdown) populates
// allowedFilePrefixes with the request working directory
// so its own assets load while sibling requests' /tmp
// paths stay out of reach. Every other route leaves the
// slice empty; treat that as default-deny so a file://
// sub-resource that slips past the deny-list (which
// exempts /tmp/) still cannot read the working
// directories of other in-flight conversions.
if allow && strings.HasPrefix(e.Request.URL, "file://") && !isAllowedFileSubResource(e.Request.URL, options.allowedFilePrefixes) {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
cctx := chromedp.FromContext(ctx)
@@ -249,6 +249,23 @@ func listenForEventResponseReceived(
})
}
// isAllowedFileSubResource reports whether a file:// sub-resource URL is
// within at least one prefix. An empty prefix list rejects every
// file:// URL so routes that never populate the list (for example
// /forms/chromium/convert/url) default-deny reads from /tmp/, blocking
// cross-request enumeration.
func isAllowedFileSubResource(rawURL string, allowedFilePrefixes []string) bool {
if len(allowedFilePrefixes) == 0 {
return false
}
for _, prefix := range allowedFilePrefixes {
if strings.HasPrefix(rawURL, "file://"+prefix) {
return true
}
}
return false
}
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
host := hostnameFromURL(rawURL)

View File

@@ -61,3 +61,49 @@ func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}
func TestIsAllowedFileSubResource(t *testing.T) {
for _, tc := range []struct {
name string
rawURL string
prefixes []string
want bool
}{
{
name: "empty prefix list default denies",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: nil,
want: false,
},
{
name: "match within the sole prefix",
rawURL: "file:///tmp/work-uuid/request-uuid/index.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: true,
},
{
name: "sibling request directory rejected",
rawURL: "file:///tmp/work-uuid/other-request-uuid/secret.html",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "parent tmp directory rejected",
rawURL: "file:///tmp/",
prefixes: []string{"/tmp/work-uuid/request-uuid"},
want: false,
},
{
name: "match among several prefixes",
rawURL: "file:///tmp/work-uuid/request-b/asset.css",
prefixes: []string{"/tmp/work-uuid/request-a", "/tmp/work-uuid/request-b"},
want: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isAllowedFileSubResource(tc.rawURL, tc.prefixes); got != tc.want {
t.Fatalf("isAllowedFileSubResource(%q, %v) = %v, want %v", tc.rawURL, tc.prefixes, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,328 @@
package chromium
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/netip"
"sync"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// pinningProxy is a loopback-bound HTTP/1.1 forward and CONNECT proxy
// placed between Chromium and the outbound network. It runs the same
// allow/deny/IP-public validation as [gotenberg.FilterOutboundURL] on
// every request and dials the destination using the IPs resolved at that
// moment. Routing Chromium through this proxy eliminates the Chromium-side
// DNS lookup that otherwise opens a DNS rebinding window between
// Gotenberg's validation and Chromium's TCP connect.
//
// The proxy is transparent to the caller. HTTPS sub-resources tunnel
// through CONNECT with Chromium performing its own TLS handshake using
// the original hostname, preserving SNI and certificate validation.
type pinningProxy struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
// decide resolves and validates a URL. Tests may override it.
decide func(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error)
// dialPinned dials the pinned IPs for a decision. Tests may override
// it to connect to a stub upstream regardless of decision.
dialPinned func(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
// dialBypass dials the destination hostname directly (operator
// allow-list opt-in). Tests may override it.
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
listener net.Listener
server *http.Server
wg sync.WaitGroup
logger *slog.Logger
started bool
mu sync.Mutex
}
// newPinningProxy returns a pinning proxy configured with the given
// allow/deny lists and IP-class policy. The policy bools are applied via
// [gotenberg.DecideOutbound] on every request the proxy sees, so
// Chromium inherits whatever posture the operator selected. The
// returned proxy is not yet listening; call Start.
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy {
return &pinningProxy{
allowList: allowList,
denyList: denyList,
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline,
gotenberg.WithDenyPrivateIPs(denyPrivateIPs),
gotenberg.WithDenyPublicIPs(denyPublicIPs),
)
},
dialPinned: gotenberg.DialPinned,
dialBypass: func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second}
return dialer.DialContext(ctx, network, addr)
},
}
}
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
// background goroutine. Bind failures return an error; the caller must
// not proceed to start Chromium with --proxy-server.
func (p *pinningProxy) Start(logger *slog.Logger) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.started {
return errors.New("pinning proxy already started")
}
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("bind pinning proxy: %w", err)
}
p.listener = l
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
p.server = &http.Server{
Handler: http.HandlerFunc(p.serveHTTP),
// Guard against slow header attacks. Body reads are controlled
// per-handler.
ReadHeaderTimeout: 15 * time.Second,
ErrorLog: slog.NewLogLogger(p.logger.Handler(), slog.LevelWarn),
}
p.wg.Go(func() {
serveErr := p.server.Serve(l)
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
p.logger.ErrorContext(context.Background(), fmt.Sprintf("pinning proxy serve: %s", serveErr))
}
})
p.started = true
p.logger.DebugContext(context.Background(), fmt.Sprintf("pinning proxy listening on %s", l.Addr()))
return nil
}
// Stop shuts the proxy down and waits for in-flight handlers to complete.
// Safe to call on a non-started proxy.
func (p *pinningProxy) Stop(logger *slog.Logger) error {
p.mu.Lock()
if !p.started {
p.mu.Unlock()
return nil
}
srv := p.server
p.started = false
p.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := srv.Shutdown(ctx)
p.wg.Wait()
if shutdownErr != nil {
return fmt.Errorf("shutdown pinning proxy: %w", shutdownErr)
}
logger.DebugContext(context.Background(), "pinning proxy stopped")
return nil
}
// URL returns the proxy URL suitable for Chromium's --proxy-server flag.
// Returns an empty string when the proxy is not listening.
func (p *pinningProxy) URL() string {
p.mu.Lock()
defer p.mu.Unlock()
if p.listener == nil {
return ""
}
return "http://" + p.listener.Addr().String()
}
func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
p.handleConnect(w, req)
return
}
p.handleForward(w, req)
}
// handleConnect handles HTTPS (and any other CONNECT) tunnels. Chromium
// issues CONNECT host:port; the proxy validates the host, dials the
// pinned IP, and splices the client socket with the upstream socket.
// Chromium then negotiates TLS end-to-end with the original hostname in
// SNI.
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
_, port, err := net.SplitHostPort(req.Host)
if err != nil {
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
return
}
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
// The validation URL uses https:// so that http-like scheme checks
// apply in [gotenberg.DecideOutbound]. The scheme does not influence
// the CONNECT handling beyond filtering.
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
http.Error(w, "CONNECT blocked", http.StatusForbidden)
return
}
var upstream net.Conn
switch {
case decision.Bypass:
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
case len(decision.Pinned) > 0:
upstream, err = p.dialPinned(req.Context(), "tcp", decision.Pinned, port)
default:
err = errors.New("no pinned addresses and not bypassed")
}
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
http.Error(w, "upstream dial failed", http.StatusBadGateway)
return
}
defer upstream.Close()
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
return
}
client, _, err := hj.Hijack()
if err != nil {
p.logger.ErrorContext(req.Context(), fmt.Sprintf("hijack CONNECT: %s", err))
return
}
defer client.Close()
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
return
}
// Splice bytes in both directions until either side closes.
var splice sync.WaitGroup
splice.Add(2)
go func() {
defer splice.Done()
_, _ = io.Copy(upstream, client)
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
go func() {
defer splice.Done()
_, _ = io.Copy(client, upstream)
if cw, ok := client.(interface{ CloseWrite() error }); ok {
_ = cw.CloseWrite()
}
}()
splice.Wait()
}
// handleForward handles plain HTTP requests sent to the proxy as absolute
// URIs (GET http://host/path). The proxy revalidates the URL, then
// forwards the request via a transport that dials the pinned IP.
func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
if req.URL == nil || req.URL.Scheme == "" || req.URL.Host == "" {
http.Error(w, "absolute URL required", http.StatusBadRequest)
return
}
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
http.Error(w, "request blocked", http.StatusForbidden)
return
}
outReq := req.Clone(req.Context())
outReq.RequestURI = ""
stripHopByHopHeaders(outReq.Header)
transport := &http.Transport{
// Build a fresh transport per request. The decision contains the
// pinned IPs to dial; reusing a transport across requests would
// leak the decision's closure across unrelated targets.
DisableKeepAlives: true,
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
}
switch {
case decision.Bypass:
return p.dialBypass(ctx, network, addr)
case len(decision.Pinned) > 0:
return p.dialPinned(ctx, network, decision.Pinned, port)
default:
return nil, errors.New("no pinned addresses and not bypassed")
}
},
}
defer transport.CloseIdleConnections()
resp, err := transport.RoundTrip(outReq)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
http.Error(w, "upstream error", http.StatusBadGateway)
return
}
defer resp.Body.Close()
copyHeaders(w.Header(), resp.Header)
stripHopByHopHeaders(w.Header())
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// Per RFC 7230 section 6.1.
var hopByHopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Proxy-Connection",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
func stripHopByHopHeaders(h http.Header) {
for _, name := range hopByHopHeaders {
h.Del(name)
}
}
func copyHeaders(dst, src http.Header) {
for k, vs := range src {
for _, v := range vs {
dst.Add(k, v)
}
}
}

View File

@@ -0,0 +1,485 @@
package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u
}
// newRawTCPServer starts a TCP server on 127.0.0.1:0 that calls handle for
// every accepted connection. It returns the listener address and a cleanup
// function.
func newRawTCPServer(t *testing.T, handle func(net.Conn)) (string, func()) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
conn, err := l.Accept()
if err != nil {
return
}
go handle(conn)
}
}()
return l.Addr().String(), func() { _ = l.Close() }
}
// newProxyForTest returns a pinning proxy whose decide and dial functions
// are set to test stubs. The proxy is started on a loopback ephemeral
// port and stopped during test cleanup.
func newProxyForTest(t *testing.T, p *pinningProxy) string {
t.Helper()
err := p.Start(testLogger())
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() {
_ = p.Stop(testLogger())
})
return p.URL()
}
func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Host != "example.com" {
t.Errorf("upstream expected Host=example.com, got %q", r.Host)
}
_, _ = fmt.Fprint(w, "hello-from-upstream")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://example.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if string(body) != "hello-from-upstream" {
t.Fatalf("body = %q, want %q", body, "hello-from-upstream")
}
if got := decideCalls.Load(); got != 1 {
t.Fatalf("decide called %d times, want 1", got)
}
}
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called when decide returns an error")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://blocked.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
}
func TestPinningProxy_Forward_Bypass(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "bypassed")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var bypassCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Bypass: true}, nil
}
p.dialBypass = func(_ context.Context, network, _ string) (net.Conn, error) {
bypassCalls.Add(1)
return net.Dial(network, upstreamURL.Host)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called on bypass")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://internal.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := bypassCalls.Load(); got != 1 {
t.Fatalf("dialBypass called %d times, want 1", got)
}
}
func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
var upstreamSawProxyAuth bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "" {
upstreamSawProxyAuth = true
}
w.Header().Set("Connection", "close")
w.Header().Set("Proxy-Connection", "close")
w.Header().Set("X-Downstream", "ok")
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Proxy-Authorization", "Basic Zm9vOmJhcg==")
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if upstreamSawProxyAuth {
t.Fatalf("upstream received Proxy-Authorization, proxy did not strip it")
}
if resp.Header.Get("Proxy-Connection") != "" {
t.Fatalf("response retained Proxy-Connection, proxy did not strip it")
}
if resp.Header.Get("X-Downstream") != "ok" {
t.Fatalf("response missing X-Downstream header")
}
}
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
t.Fatal("decide must not be called for malformed proxy request")
return gotenberg.OutboundDecision{}, nil
}
proxyURL := newProxyForTest(t, p)
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
// Send a request with a path-only target, not an absolute URI, which
// the proxy should reject with 400.
_, err = fmt.Fprint(conn, "GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n")
if err != nil {
t.Fatalf("write request: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
defer c.Close()
_, _ = c.Write([]byte("HI"))
buf := make([]byte, 4)
n, _ := io.ReadFull(c, buf)
_, _ = c.Write(buf[:n])
})
t.Cleanup(stop)
var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
}
p.dialPinned = func(_ context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
return net.Dial(network, upstreamAddr)
}
proxyURL := newProxyForTest(t, p)
// Connect to the proxy, send CONNECT, splice raw bytes.
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
br := bufio.NewReader(conn)
statusLine, err := br.ReadString('\n')
if err != nil {
t.Fatalf("read status: %v", err)
}
if !strings.Contains(statusLine, " 200 ") {
t.Fatalf("CONNECT status = %q, want 200", statusLine)
}
// Consume the blank line after headers.
for {
line, err := br.ReadString('\n')
if err != nil {
t.Fatalf("read headers: %v", err)
}
if line == "\r\n" || line == "\n" {
break
}
}
hi := make([]byte, 2)
_, err = io.ReadFull(br, hi)
if err != nil {
t.Fatalf("read greeting: %v", err)
}
if string(hi) != "HI" {
t.Fatalf("greeting = %q, want HI", hi)
}
_, err = conn.Write([]byte("PONG"))
if err != nil {
t.Fatalf("write PONG: %v", err)
}
echo := make([]byte, 4)
_, err = io.ReadFull(br, echo)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(echo) != "PONG" {
t.Fatalf("echo = %q, want PONG", echo)
}
if got := decideCalls.Load(); got != 1 {
t.Fatalf("decide called %d times, want 1", got)
}
}
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
}
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
t.Fatal("dialPinned must not be called when decide returns an error")
return nil, errors.New("unreachable")
}
proxyURL := newProxyForTest(t, p)
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
_, err = fmt.Fprintf(conn, "CONNECT rebind.example:443 HTTP/1.1\r\nHost: rebind.example:443\r\n\r\n")
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
if err != nil {
t.Fatalf("read response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("CONNECT status = %d, want 403", resp.StatusCode)
}
}
// TestPinningProxy_DNSRebind_SingleResolution is the regression test for
// the DNS rebinding window. It simulates a DNS authority that returns a
// public IP on the first lookup and a loopback IP on subsequent lookups.
// The proxy must resolve the host exactly once per request and dial the
// IP validated at that moment, so that a second resolution by any later
// layer cannot pivot the connection to an internal target.
func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "public-upstream")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
var lookupCount atomic.Int32
stubDecide := func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
n := lookupCount.Add(1)
if n == 1 {
// First lookup: returns a public IP, validation passes, the
// proxy pins it for the dial.
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("93.184.216.34")}}, nil
}
// Any subsequent lookup for the same host would return a
// loopback IP. This return value must not influence the dial
// because the proxy must not call decide again for this request.
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
}
p := newPinningProxy(nil, nil, false, false)
p.decide = stubDecide
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) {
if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
t.Errorf("dialPinned got addrs %v, want [93.184.216.34]", addrs)
}
return net.Dial(network, upstreamURL.Host)
}
proxyURL := newProxyForTest(t, p)
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://rebind.example/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if string(body) != "public-upstream" {
t.Fatalf("body = %q, want %q", body, "public-upstream")
}
if got := lookupCount.Load(); got != 1 {
t.Fatalf("decide called %d times, want exactly 1 (rebind protection)", got)
}
}
func TestPinningProxy_StartTwice(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
err := p.Start(testLogger())
if err != nil {
t.Fatalf("first Start: %v", err)
}
t.Cleanup(func() { _ = p.Stop(testLogger()) })
err = p.Start(testLogger())
if err == nil {
t.Fatal("second Start: expected error, got nil")
}
}
func TestPinningProxy_StopIdempotent(t *testing.T) {
p := newPinningProxy(nil, nil, false, false)
// Stop on a never-started proxy is a no-op.
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("Stop on never-started proxy: %v", err)
}
if err := p.Start(testLogger()); err != nil {
t.Fatalf("Start: %v", err)
}
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("first Stop: %v", err)
}
if err := p.Stop(testLogger()); err != nil {
t.Fatalf("second Stop on stopped proxy: %v", err)
}
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@@ -402,6 +403,34 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
return form, screenshotOptions
}
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
// file:// scheme. /forms/chromium/convert/url and
// /forms/chromium/screenshot/url accept user-supplied URLs and are
// intended for navigating to remote HTTP(S) resources; allowing file://
// lets a caller reach Chromium's working directory through the default
// deny-list's /tmp/ allowance, which exists only to serve main-page
// HTML/Markdown that the other routes generate. Filter the scheme at the
// route layer where no request-scoped allowedFilePrefixes exists.
func rejectFileScheme(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return api.WrapError(
fmt.Errorf("parse URL: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
)
}
if strings.EqualFold(parsed.Scheme, "file") {
return api.WrapError(
fmt.Errorf("file:// scheme not allowed on URL route"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
),
)
}
return nil
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -431,11 +460,18 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
@@ -467,6 +503,11 @@ func screenshotUrlRoute(chromium Api) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("URL screenshot: %w", err)
@@ -507,11 +548,13 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url := fmt.Sprintf("file://%s", inputPath)
@@ -592,11 +635,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)

View File

@@ -2,17 +2,16 @@ package exiftool
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"reflect"
"regexp"
"strings"
"syscall"
"github.com/barasher/go-exiftool"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
@@ -25,22 +24,20 @@ func init() {
}
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
// hyphens, underscores, colons, and periods. Rejects control characters
// (especially \n) that would inject stdin arguments via go-exiftool's
// line-based protocol.
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9\-_.:]+$`)
// hyphens, underscores, colons, and periods. The first character may not
// be a hyphen, otherwise exiftool would treat the argv entry as a flag
// rather than a tag assignment. Control characters are implicitly
// rejected because the class is ASCII-only.
var safeKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9_.:][a-zA-Z0-9\-_.:]*$`)
// validateMetadataValue rejects metadata values containing characters that
// could inject ExifTool stdin arguments. go-exiftool writes each key/value
// pair as a single line via fmt.Fprintln(stdin, "-"+k+"="+str), so a
// newline in the value splits into a second stdin line that ExifTool
// interprets as an additional argument. An attacker who controls a
// metadata value but not the key could otherwise inject pseudo-tags like
// -FileName=, -Directory=, -SymLink=, or -HardLink= and trigger arbitrary
// filesystem side effects. The same applies to carriage returns and NUL.
//
// The returned error wraps [gotenberg.ErrPdfEngineMetadataValueNotSupported]
// so the API layer surfaces it as HTTP 400.
// validateMetadataValue rejects metadata values containing NUL, newline,
// or carriage return. NUL terminates C strings and is rejected by
// [exec.Cmd] anyway; newlines and carriage returns are rejected as
// defense in depth against exiftool parsing quirks, even though argv
// invocation is not susceptible to stdin-protocol injection the way
// the previous go-exiftool backend was. The returned error wraps
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
// surfaces it as HTTP 400.
func validateMetadataValue(key, value string) error {
if strings.ContainsAny(value, "\n\r\x00") {
return fmt.Errorf("write PDF metadata with ExifTool: invalid metadata value for key %q (contains control character): %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
@@ -49,9 +46,10 @@ func validateMetadataValue(key, value string) error {
}
// systemTags lists ExifTool tags that reflect internal filesystem state
// rather than actual PDF metadata. These are stripped from both read and
// write operations.
// or tool identity rather than actual PDF metadata. Stripped from read
// output before returning to the caller.
var systemTags = []string{
"SourceFile", // Full path exiftool -j always emits first
"FileName", // Reflects UUID-based disk name, not original filename
"Directory", // Leaks internal temp path
"FileSize", // System attribute
@@ -64,16 +62,97 @@ var systemTags = []string{
"Warning", // Extraction warning messages
}
// writeOnlyDerivedTags lists ExifTool tags that are safe to return when
// reading metadata but should not be written back (writing them can break
// PDF/A compliance or cause side effects).
var writeOnlyDerivedTags = []string{
"PageCount", // Causes prism:pageCount injection
"Linearized", // Computed status; writing it may invalidate structure
"PDFVersion", // Header version; should not be manually forced via metadata
"MIMEType", // Read-only derived
"FileType", // Read-only derived
"FileTypeExtension", // Read-only derived
// dangerousTags lists ExifTool pseudo-tags that trigger filesystem side
// effects (file rename, move, link creation, permission change). Writes
// containing any of these keys are silently dropped before the argv is
// handed to exiftool. The comparison strips group prefixes (e.g.
// "System:FileName" collapses to "FileName") because exiftool treats
// the prefixed and bare forms identically.
//
// See https://exiftool.org/TagNames/Extra.html.
var dangerousTags = []string{
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // Writing this triggers a file move in ExifTool
"HardLink", // Writing this creates a hard link in ExifTool
"SymLink", // Writing this creates a symbolic link in ExifTool
"FilePermissions", // Writing this changes the file's permissions
}
// isDangerousTag reports whether key matches one of the [dangerousTags]
// after case-insensitive comparison with any group prefix stripped.
func isDangerousTag(key string) bool {
bare := key
if i := strings.LastIndex(key, ":"); i >= 0 {
bare = key[i+1:]
}
for _, tag := range dangerousTags {
if strings.EqualFold(bare, tag) {
return true
}
}
return false
}
// buildExifToolWriteArgs builds the variadic argv tail for
//
// exiftool -overwrite_original <args> <path>
//
// from a user-supplied metadata map. Dangerous pseudo-tags are silently
// dropped. Invalid keys (empty, leading dash, control characters) and
// values containing NUL or newlines return an error wrapping
// [gotenberg.ErrPdfEngineMetadataValueNotSupported] so the API layer
// replies with HTTP 400. Supported value kinds: string, []string,
// []any of strings, bool, int, int64, float32, float64.
func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) {
var args []string
for key, value := range metadata {
if isDangerousTag(key) {
continue
}
if !safeKeyPattern.MatchString(key) {
return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
switch val := value.(type) {
case string:
if err := validateMetadataValue(key, val); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, val))
case []string:
for _, s := range val {
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
}
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
for _, entry := range val {
s, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported element type %T in []any for key %q: %w", entry, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
if err := validateMetadataValue(key, s); err != nil {
return nil, err
}
args = append(args, fmt.Sprintf("-%s=%s", key, s))
}
case bool:
args = append(args, fmt.Sprintf("-%s=%t", key, val))
case int:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
case int64:
args = append(args, fmt.Sprintf("-%s=%d", key, val))
case float32:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
case float64:
args = append(args, fmt.Sprintf("-%s=%g", key, val))
default:
return nil, fmt.Errorf("write PDF metadata with ExifTool: unsupported type %T for key %q: %w", value, key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
}
return args, nil
}
// ExifTool abstracts the CLI tool ExifTool and implements the
@@ -185,7 +264,8 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format
return err
}
// ReadMetadata extracts the metadata of a given PDF file.
// ReadMetadata extracts the metadata of a given PDF file by invoking
// the exiftool binary with "-j" (JSON output) and parsing the result.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
@@ -193,40 +273,56 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
cmd := exec.CommandContext(ctx, engine.binPath, "-j", inputPath) //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
output, err := cmd.Output()
if err != nil {
err = fmt.Errorf("new ExifTool: %w", err)
err = fmt.Errorf("read metadata with ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
var files []map[string]any
err = json.Unmarshal(output, &files)
if err != nil {
err = fmt.Errorf("parse ExifTool JSON output: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if len(files) == 0 {
err = errors.New("ExifTool returned no file entries")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
metadata := files[0]
// ExifTool records extraction errors as an "Error" key on the file
// entry rather than via a non-zero exit code. Surface that back as a
// Go error before stripping so callers see the real cause.
if msg, ok := metadata["Error"].(string); ok && msg != "" {
err = fmt.Errorf("read metadata with ExifTool: %s", msg)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Strip system tags that reflect internal filesystem state (e.g.,
// UUID-based FileName, temp Directory) rather than actual PDF metadata.
for _, tag := range systemTags {
delete(fileMetadata[0].Fields, tag)
delete(metadata, tag)
}
span.SetStatus(codes.Ok, "")
return fileMetadata[0].Fields, nil
return metadata, nil
}
// WriteMetadata writes the metadata into a given PDF file.
// WriteMetadata writes the metadata into a given PDF file by invoking
// the exiftool binary with "-overwrite_original -TAG=VALUE ... path".
// ExifTool preserves tags that are not mentioned in the argv, so the
// write is a merge rather than a rewrite.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
@@ -234,143 +330,34 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger,
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
extraArgs, err := buildExifToolWriteArgs(metadata)
if err != nil {
err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
if len(extraArgs) == 0 {
// Nothing to write after filtering. Treat as success so the
// caller can move on without a dedicated zero-tag branch.
span.SetStatus(codes.Ok, "")
return nil
}
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
args := append([]string{"-overwrite_original"}, extraArgs...)
args = append(args, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
err = fmt.Errorf("create ExifTool command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Strip system and derived tags from the existing file metadata so
// they are not written back (which can break PDF/A compliance or
// cause side effects).
for _, tag := range systemTags {
delete(fileMetadata[0].Fields, tag)
}
for _, tag := range writeOnlyDerivedTags {
delete(fileMetadata[0].Fields, tag)
}
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
// triggering dangerous side effects like file renames, moves, link
// creation, or permission changes. Comparison is case-insensitive
// because ExifTool processes tag names case-insensitively, and group
// prefixes are stripped because ExifTool treats "System:FileName" the
// same as "FileName".
// See https://exiftool.org/TagNames/Extra.html.
dangerousTags := []string{
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // Writing this triggers a file move in ExifTool
"HardLink", // Writing this creates a hard link in ExifTool
"SymLink", // Writing this creates a symbolic link in ExifTool
"FilePermissions", // Writing this changes the file's permissions
}
// Reject metadata keys containing characters that could inject ExifTool
// stdin arguments. ExifTool uses a line-based stdin protocol; a newline
// in a key splits into a separate argument, enabling flag injection
// (e.g., -if with Perl eval). Only allow alphanumeric, hyphen, colon,
// period, and underscore — sufficient for all legitimate tag names.
for key := range metadata {
if !safeKeyPattern.MatchString(key) {
err = fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
}
for key := range metadata {
// Strip ExifTool group prefixes (e.g., "System:FileName" →
// "FileName") before comparing. ExifTool allows leading group
// names separated by colons, and treats the prefixed and bare
// forms identically.
bare := key
if i := strings.LastIndex(key, ":"); i >= 0 {
bare = key[i+1:]
}
for _, tag := range dangerousTags {
if strings.EqualFold(bare, tag) {
delete(metadata, key)
}
}
}
for key, value := range metadata {
switch val := value.(type) {
case string:
if err = validateMetadataValue(key, val); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
fileMetadata[0].SetString(key, val)
case []string:
for _, s := range val {
if err = validateMetadataValue(key, s); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
}
fileMetadata[0].SetStrings(key, val)
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strs := make([]string, len(val))
for i, entry := range val {
str, ok := entry.(string)
if !ok {
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if err = validateMetadataValue(key, str); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
strs[i] = str
}
fileMetadata[0].SetStrings(key, strs)
case bool:
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
case int:
fileMetadata[0].SetInt(key, int64(val))
case int64:
fileMetadata[0].SetInt(key, val)
case float32:
fileMetadata[0].SetFloat(key, float64(val))
case float64:
fileMetadata[0].SetFloat(key, val)
// TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library).
default:
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
}
exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil {
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
exitCode, err := cmd.Exec()
if err != nil {
err = fmt.Errorf("write PDF metadata with ExifTool (exit %d): %w", exitCode, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err

View File

@@ -0,0 +1,213 @@
package exiftool
import (
"errors"
"slices"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestBuildExifToolWriteArgs_String(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Title": "sample"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Title=sample"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_StringSlice(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []string{"first", "second"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Keywords=first", "-Keywords=second"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_AnySliceOfStrings(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", "b"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Keywords=a", "-Keywords=b"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_AnySliceMixedRejected(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"Keywords": []any{"a", 42}})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
}
}
func TestBuildExifToolWriteArgs_Numbers(t *testing.T) {
for _, tc := range []struct {
name string
in any
want string
}{
{"int", 42, "-K=42"},
{"int64", int64(42), "-K=42"},
{"float32", float32(1.5), "-K=1.5"},
{"float64", 1.7, "-K=1.7"},
} {
t.Run(tc.name, func(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"K": tc.in})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(args) != 1 || args[0] != tc.want {
t.Fatalf("args = %v, want [%q]", args, tc.want)
}
})
}
}
func TestBuildExifToolWriteArgs_Bool(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{"Marked": true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"-Marked=true"}
if !slices.Equal(args, want) {
t.Fatalf("args = %v, want %v", args, want)
}
}
func TestBuildExifToolWriteArgs_InvalidKey(t *testing.T) {
for _, key := range []string{
"", // empty
"-rm", // leading dash — would be parsed as a flag
"foo\nbar", // newline
"foo bar", // space
"foo=bar", // contains equals
"weird/char", // slash
} {
t.Run(key, func(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{key: "value"})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for key %q, got %v", key, err)
}
})
}
}
func TestBuildExifToolWriteArgs_ControlCharValue(t *testing.T) {
for _, val := range []string{
"foo\nbar",
"foo\rbar",
"foo\x00bar",
} {
t.Run(val, func(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"Title": val})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported for value %q, got %v", val, err)
}
})
}
}
func TestBuildExifToolWriteArgs_DangerousTagsStripped(t *testing.T) {
// Dangerous tag keys are silently dropped; legitimate keys still pass.
args, err := buildExifToolWriteArgs(map[string]any{
"Author": "legit",
"FileName": "stolen.pdf",
"System:FileName": "stolen.pdf",
"Directory": "/tmp",
"HardLink": "/tmp/link",
"SymLink": "/tmp/link",
"FilePermissions": "777",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(args, []string{"-Author=legit"}) {
t.Fatalf("args = %v, want [-Author=legit]", args)
}
}
func TestBuildExifToolWriteArgs_DangerousTagsCaseInsensitive(t *testing.T) {
// Case variations are all dropped because exiftool is case-insensitive.
args, err := buildExifToolWriteArgs(map[string]any{
"filename": "x",
"FILENAME": "x",
"System:Filename": "x",
"Title": "keep",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(args, []string{"-Title=keep"}) {
t.Fatalf("args = %v, want [-Title=keep]", args)
}
}
func TestBuildExifToolWriteArgs_UnsupportedType(t *testing.T) {
_, err := buildExifToolWriteArgs(map[string]any{"K": map[string]any{"nested": "x"}})
if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) {
t.Fatalf("expected ErrPdfEngineMetadataValueNotSupported, got %v", err)
}
}
func TestBuildExifToolWriteArgs_Empty(t *testing.T) {
args, err := buildExifToolWriteArgs(map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(args) != 0 {
t.Fatalf("args = %v, want empty", args)
}
}
func TestIsDangerousTag(t *testing.T) {
for _, tc := range []struct {
key string
want bool
}{
{"FileName", true},
{"filename", true},
{"System:FileName", true},
{"XMP:FileName", true},
{"Directory", true},
{"HardLink", true},
{"SymLink", true},
{"FilePermissions", true},
{"Title", false},
{"Author", false},
{"FileNameExtra", false}, // Suffix must not match.
{"", false},
} {
t.Run(tc.key, func(t *testing.T) {
if got := isDangerousTag(tc.key); got != tc.want {
t.Fatalf("isDangerousTag(%q) = %v, want %v", tc.key, got, tc.want)
}
})
}
}
func TestSafeKeyPattern(t *testing.T) {
// Rejects leading dash to prevent argv-level flag injection.
if safeKeyPattern.MatchString("-injected") {
t.Fatalf("leading-dash key must be rejected")
}
// Accepts common legitimate forms.
for _, k := range []string{"Title", "System:Title", "XMP-pdf:Title", "My_Tag.1"} {
if !safeKeyPattern.MatchString(k) {
t.Fatalf("key %q must be accepted", k)
}
}
// Rejects control characters.
for _, k := range []string{"a\nb", "a\rb", "a\x00b", "a b"} {
if safeKeyPattern.MatchString(k) {
t.Fatalf("control-char key %q must be rejected", k)
}
}
}

View File

@@ -327,6 +327,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values")
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values")
fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services")
fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound 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")
return fs
}(),
@@ -353,6 +357,12 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
binPath: libreOfficeBinPath,
unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"),
proxyOptions: outboundProxyOptions{
allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
},
}
// Logger.

View File

@@ -24,12 +24,14 @@ type libreOfficeArguments struct {
binPath string
unoBinPath string
startTimeout time.Duration
proxyOptions outboundProxyOptions
}
type libreOfficeProcess struct {
socketPort int
userProfileDirPath string
cmd *gotenberg.Cmd
proxy *libreOfficeProxy
cfgMu sync.RWMutex
isStarted atomic.Bool
@@ -57,7 +59,24 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
return fmt.Errorf("get free port: %w", err)
}
proxy, err := newLibreOfficeProxy(logger, p.arguments.proxyOptions)
if err != nil {
return fmt.Errorf("create LibreOffice outbound proxy: %w", err)
}
proxy.Start()
userProfileDirPath := p.fs.NewDirPath()
// LibreOffice fetches external content (OOXML images via
// TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside
// its own libcurl. Route those fetches through the in-process proxy
// so the chromium/webhook SSRF filters apply.
if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("write soffice proxy config: %w", err)
}
sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr())
args := []string{
"--headless",
"--invisible",
@@ -75,13 +94,16 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...)
if err != nil {
_ = proxy.Stop(context.Background())
return fmt.Errorf("create LibreOffice command: %w", err)
}
cmd.SetEnv(sofficeEnv)
// For whatever reason, LibreOffice requires a first start before being
// able to run as a daemon.
exitCode, err := cmd.Exec()
if err != nil && exitCode != 81 {
_ = proxy.Stop(context.Background())
return fmt.Errorf("execute LibreOffice: %w", err)
}
@@ -89,6 +111,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
cmd.SetEnv(sofficeEnv)
err = cmd.Start()
if err != nil {
@@ -139,11 +162,18 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
p.socketPort = port
p.userProfileDirPath = userProfileDirPath
p.cmd = cmd
p.proxy = proxy
p.isStarted.Store(true)
return
}
// LibreOffice failed to start; tear the proxy down too.
stopErr := proxy.Stop(context.Background())
if stopErr != nil {
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy after failed start: %s", stopErr))
}
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
@@ -212,6 +242,16 @@ func (p *libreOfficeProcess) Stop(logger *slog.Logger) error {
return fmt.Errorf("kill LibreOffice process: %w", err)
}
if p.proxy != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
stopErr := p.proxy.Stop(shutdownCtx)
cancel()
if stopErr != nil {
logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy: %s", stopErr))
}
p.proxy = nil
}
p.socketPort = 0
p.userProfileDirPath = ""
p.cmd = nil

View File

@@ -0,0 +1,323 @@
package api
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// outboundProxyOptions configures a [libreOfficeProxy].
type outboundProxyOptions struct {
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
}
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
// outbound requests through. Every proxied request goes through
// [gotenberg.DecideOutbound] so the same allow/deny lists and IP-class
// filters that protect chromium and webhook fetches also apply to
// soffice's own libcurl-driven fetches.
//
// soffice triggers an outbound request whenever a document references
// external content (OOXML images via TargetMode="External", RTF
// INCLUDEPICTURE, ODT linked images). Without a filtering proxy in the
// path those fetches bypass every Go-side SSRF guard because they
// originate inside the soffice subprocess.
type libreOfficeProxy struct {
listener net.Listener
server *http.Server
client *http.Client
opts outboundProxyOptions
logger *slog.Logger
stopOnce sync.Once
}
// newLibreOfficeProxy binds a proxy listener to a free local port and
// applies opts to every proxied request. Callers must call [Start]
// before pointing soffice at the proxy and [Stop] on shutdown.
func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libreOfficeProxy, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("bind LibreOffice proxy listener: %w", err)
}
decideOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(opts.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(opts.denyPublicIPs),
}
p := &libreOfficeProxy{
listener: listener,
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...),
opts: opts,
logger: logger.With(slog.String("logger", "libreoffice-proxy")),
}
p.server = &http.Server{
Handler: p,
ReadHeaderTimeout: 10 * time.Second,
}
return p, nil
}
// Addr returns the host:port the proxy listens on.
func (p *libreOfficeProxy) Addr() string {
return p.listener.Addr().String()
}
// Start serves proxy requests in a background goroutine until [Stop] is
// called.
func (p *libreOfficeProxy) Start() {
go func() {
err := p.server.Serve(p.listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
p.logger.ErrorContext(context.Background(), fmt.Sprintf("LibreOffice proxy serve: %s", err))
}
}()
}
// Stop gracefully shuts the proxy down. Subsequent calls are no-ops.
func (p *libreOfficeProxy) Stop(ctx context.Context) error {
var err error
p.stopOnce.Do(func() {
err = p.server.Shutdown(ctx)
})
if err != nil {
return fmt.Errorf("shutdown LibreOffice proxy: %w", err)
}
return nil
}
// ServeHTTP dispatches between CONNECT (HTTPS tunnels) and the absolute
// URL form (HTTP forward).
func (p *libreOfficeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
p.handleConnect(w, r)
return
}
p.handleHttp(w, r)
}
// handleHttp forwards a plain HTTP request whose URL line is absolute
// (RFC 7230 5.3.2) through the outbound HTTP client, which validates
// the destination and pins the dial.
func (p *libreOfficeProxy) handleHttp(w http.ResponseWriter, r *http.Request) {
if r.URL == nil || !r.URL.IsAbs() {
http.Error(w, "proxy: expected absolute URI", http.StatusBadRequest)
return
}
outReq := r.Clone(r.Context())
outReq.RequestURI = ""
removeHopByHopHeaders(outReq.Header)
// gosec G704: outReq.URL is exactly what the proxy is here to filter; the
// http.Client returned by NewOutboundHttpClient validates and pins it.
resp, err := p.client.Do(outReq) //nolint:gosec
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected forward to '%s': %s", r.URL.String(), err))
http.Error(w, "proxy: destination rejected", http.StatusForbidden)
return
}
defer func() {
closeErr := resp.Body.Close()
if closeErr != nil {
p.logger.DebugContext(r.Context(), fmt.Sprintf("close upstream response body: %s", closeErr))
}
}()
removeHopByHopHeaders(resp.Header)
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
_, copyErr := io.Copy(w, resp.Body)
if copyErr != nil {
p.logger.DebugContext(r.Context(), fmt.Sprintf("copy proxied response body: %s", copyErr))
}
}
// handleConnect implements an HTTPS tunnel. It validates the destination
// host through [gotenberg.DecideOutbound] (synthesizing an https URL),
// dials the pinned IPs returned by the decision, and splices bytes
// between client and server.
func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
host, port, err := net.SplitHostPort(r.Host)
if err != nil {
http.Error(w, "proxy: invalid CONNECT target", http.StatusBadRequest)
return
}
deadline, ok := r.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
rawURL := (&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)}).String()
decision, err := gotenberg.DecideOutbound(r.Context(), rawURL, p.opts.allowList, p.opts.denyList, deadline,
gotenberg.WithDenyPrivateIPs(p.opts.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(p.opts.denyPublicIPs),
)
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected CONNECT to '%s': %s", rawURL, err))
http.Error(w, "proxy: destination rejected", http.StatusForbidden)
return
}
var dest net.Conn
switch {
case len(decision.Pinned) > 0:
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
default:
// Bypass (allow-list match) or non-http-like scheme: dial directly.
// gosec G704: host:port has cleared DecideOutbound above.
dest, err = net.DialTimeout("tcp", net.JoinHostPort(host, port), 10*time.Second) //nolint:gosec
}
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy CONNECT dial to '%s' failed: %s", rawURL, err))
http.Error(w, "proxy: dial failed", http.StatusBadGateway)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
_ = dest.Close()
http.Error(w, "proxy: hijack unsupported", http.StatusInternalServerError)
return
}
client, _, err := hijacker.Hijack()
if err != nil {
_ = dest.Close()
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy hijack failed: %s", err))
return
}
_, writeErr := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
if writeErr != nil {
_ = client.Close()
_ = dest.Close()
return
}
go pipeAndClose(client, dest)
go pipeAndClose(dest, client)
}
// pipeAndClose copies bytes from src to dst and closes both ends when
// the copy finishes.
func pipeAndClose(dst, src net.Conn) {
defer func() {
_ = dst.Close()
_ = src.Close()
}()
_, _ = io.Copy(dst, src)
}
// hopByHopHeaders is the set of hop-by-hop headers from RFC 7230 6.1
// plus the ones soffice adds when acting as a forward-proxy client.
var hopByHopHeaders = []string{
"Connection",
"Proxy-Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te",
"Trailer",
"Transfer-Encoding",
"Upgrade",
}
// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that
// tells soffice's UCB layer to route every HTTP and HTTPS fetch through
// proxyHost:proxyPort. The %s placeholders accept the proxy host and
// port respectively (host first, port second, repeated for HTTP and
// HTTPS).
const sofficeProxyConfigTmpl = `<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyName" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyPort" oor:op="fuse"><value>%s</value></prop></item>
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetNoProxy" oor:op="fuse"><value></value></prop></item>
</oor:items>
`
// writeSofficeProxyConfig drops a registrymodifications.xcu file into
// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr
// for both HTTP and HTTPS. proxyAddr must be a host:port pair.
func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error {
host, port, err := net.SplitHostPort(proxyAddr)
if err != nil {
return fmt.Errorf("split proxy address %q: %w", proxyAddr, err)
}
userDir := userProfileDirPath + "/user"
err = os.MkdirAll(userDir, 0o755)
if err != nil {
return fmt.Errorf("create soffice user profile directory: %w", err)
}
body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port)
err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600)
if err != nil {
return fmt.Errorf("write registrymodifications.xcu: %w", err)
}
return nil
}
// sofficeProxyEnv overlays http_proxy/https_proxy on env so soffice's
// libcurl path also routes through proxyAddr. The environment variables
// supplement the registrymodifications.xcu config so coverage stays
// intact if soffice upgrades and one of the two paths regresses.
func sofficeProxyEnv(env []string, proxyAddr string) []string {
proxyURL := "http://" + proxyAddr
filtered := env[:0:0]
for _, kv := range env {
switch strings.ToLower(strings.SplitN(kv, "=", 2)[0]) {
case "http_proxy", "https_proxy", "no_proxy":
continue
}
filtered = append(filtered, kv)
}
return append(filtered,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"no_proxy=",
"NO_PROXY=",
)
}
func removeHopByHopHeaders(h http.Header) {
if connection := h.Get("Connection"); connection != "" {
for name := range strings.SplitSeq(connection, ",") {
h.Del(strings.TrimSpace(name))
}
}
for _, name := range hopByHopHeaders {
h.Del(name)
}
}

View File

@@ -0,0 +1,369 @@
package api
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp {
t.Helper()
out := make([]*regexp2.Regexp, 0, len(patterns))
for _, p := range patterns {
r, err := regexp2.Compile(p, 0)
if err != nil {
t.Fatalf("compile %q: %v", p, err)
}
out = append(out, r)
}
return out
}
func startProxy(t *testing.T, opts outboundProxyOptions) *libreOfficeProxy {
t.Helper()
p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), opts)
if err != nil {
t.Fatalf("new proxy: %v", err)
}
p.Start()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = p.Stop(ctx)
})
return p
}
func TestLibreOfficeProxy_HttpForwardAllowed(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
_, _ = w.Write([]byte("hello"))
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusTeapot {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusTeapot)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "hello" {
t.Fatalf("body: got %q, want %q", body, "hello")
}
}
func TestLibreOfficeProxy_HttpForwardDenyListRejects(t *testing.T) {
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("origin must not be reached")
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{
denyList: compileRegexes(t, `.*`),
})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestLibreOfficeProxy_HttpForwardDenyPrivateIPsRejects(t *testing.T) {
// httptest binds on 127.0.0.1 (a private IP), so denyPrivateIPs
// must reject the forward.
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("origin must not be reached")
}))
defer origin.Close()
p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true})
proxyURL, _ := url.Parse("http://" + p.Addr())
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
Timeout: 5 * time.Second,
}
resp, err := client.Get(origin.URL + "/foo")
if err != nil {
t.Fatalf("client.Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden)
}
}
func TestLibreOfficeProxy_ConnectTunnelHappyPath(t *testing.T) {
// Bring up a tiny TCP echo server.
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen echo: %v", err)
}
defer listener.Close()
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer conn.Close()
_, _ = io.Copy(conn, conn)
}()
p := startProxy(t, outboundProxyOptions{})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read CONNECT response: %v", err)
}
if !strings.Contains(statusLine, "200") {
t.Fatalf("CONNECT status: got %q, want 200", statusLine)
}
// Drain remaining headers.
for {
line, readErr := reader.ReadString('\n')
if readErr != nil {
t.Fatalf("read CONNECT headers: %v", readErr)
}
if line == "\r\n" || line == "\n" {
break
}
}
// Tunnel established. Round-trip a payload through the echo server.
want := "ping"
_, err = conn.Write([]byte(want))
if err != nil {
t.Fatalf("write payload: %v", err)
}
got := make([]byte, len(want))
_, err = io.ReadFull(reader, got)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(got) != want {
t.Fatalf("echo: got %q, want %q", got, want)
}
}
func TestLibreOfficeProxy_ConnectDenyListRejects(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
p := startProxy(t, outboundProxyOptions{denyList: compileRegexes(t, `.*`)})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
if !strings.Contains(statusLine, "403") {
t.Fatalf("CONNECT status: got %q, want 403", statusLine)
}
}
func TestLibreOfficeProxy_ConnectDenyPrivateIPsRejects(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer listener.Close()
p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true})
conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
defer conn.Close()
// 127.0.0.1 is a private IP under denyPrivateIPs.
target := listener.Addr().String()
_, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
if err != nil {
t.Fatalf("write CONNECT: %v", err)
}
reader := bufio.NewReader(conn)
statusLine, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read response: %v", err)
}
if !strings.Contains(statusLine, "403") {
t.Fatalf("CONNECT status: got %q, want 403", statusLine)
}
}
func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) {
p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), outboundProxyOptions{})
if err != nil {
t.Fatalf("new proxy: %v", err)
}
p.Start()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := p.Stop(ctx); err != nil {
t.Fatalf("first Stop: %v", err)
}
if err := p.Stop(ctx); err != nil {
t.Fatalf("second Stop: %v", err)
}
}
func TestWriteSofficeProxyConfig(t *testing.T) {
dir := t.TempDir()
if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil {
t.Fatalf("writeSofficeProxyConfig: %v", err)
}
body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu"))
if err != nil {
t.Fatalf("read xcu: %v", err)
}
for _, want := range []string{
`ooInetProxyType`, `<value>1</value>`,
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
`ooInetHTTPProxyPort`, `<value>9876</value>`,
`ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`,
} {
if !strings.Contains(string(body), want) {
t.Errorf("xcu missing %q\nfull body:\n%s", want, body)
}
}
}
func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) {
err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port")
if err == nil {
t.Fatal("expected error for malformed proxy address")
}
if !errors.Is(err, errors.Unwrap(err)) {
// Only checking that an error was returned; underlying error type is
// implementation detail.
_ = err
}
}
func TestSofficeProxyEnv_OverridesExisting(t *testing.T) {
in := []string{
"PATH=/usr/bin",
"http_proxy=http://attacker:1",
"HTTPS_PROXY=http://attacker:1",
"NO_PROXY=internal",
"USER=gotenberg",
}
out := sofficeProxyEnv(in, "127.0.0.1:9876")
want := map[string]string{
"http_proxy": "http://127.0.0.1:9876",
"https_proxy": "http://127.0.0.1:9876",
"HTTP_PROXY": "http://127.0.0.1:9876",
"HTTPS_PROXY": "http://127.0.0.1:9876",
"no_proxy": "",
"NO_PROXY": "",
}
got := map[string]string{}
for _, kv := range out {
parts := strings.SplitN(kv, "=", 2)
got[parts[0]] = parts[1]
}
for key, value := range want {
if got[key] != value {
t.Errorf("env[%s]: got %q, want %q", key, got[key], value)
}
}
// Pre-existing unrelated keys must survive.
if got["PATH"] != "/usr/bin" {
t.Errorf("env[PATH]: got %q, want /usr/bin", got["PATH"])
}
if got["USER"] != "gotenberg" {
t.Errorf("env[USER]: got %q, want gotenberg", got["USER"])
}
// Old proxy values must be gone, not duplicated. Count exact-case keys.
counts := map[string]int{}
for _, kv := range out {
key := strings.SplitN(kv, "=", 2)[0]
counts[key]++
}
for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} {
if counts[key] != 1 {
t.Errorf("env[%s] count: got %d, want 1", key, counts[key])
}
}
}

View File

@@ -304,11 +304,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = pdfengines.EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)

View File

@@ -142,14 +142,14 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten
switch mode.Mode {
case gotenberg.SplitModeIntervals:
args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
args = append(args, "split", "--mode", "span", inputPath, outputDirPath, mode.Span)
case gotenberg.SplitModePages:
if mode.Unify {
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
args = append(args, "trim", "--pages", mode.Span, inputPath, outputPath)
break
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
args = append(args, "extract", "--mode", "page", "--pages", mode.Span, inputPath, outputDirPath)
default:
err := fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
@@ -426,7 +426,7 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
}
}()
args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath}
args := []string{"bookmarks", "import", "--replace", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
err = fmt.Errorf("create command: %w", err)
@@ -513,10 +513,10 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
args := make([]string, 0, 11)
args = append(args, "encrypt")
args = append(args, "-mode", "aes")
args = append(args, "-upw", userPassword)
args = append(args, "-opw", ownerPassword)
args = append(args, "-perm", "all")
args = append(args, "--mode", "aes")
args = append(args, "--upw", userPassword)
args = append(args, "--opw", ownerPassword)
args = append(args, "--perm", "all")
args = append(args, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -587,7 +587,7 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath
args := []string{"rotate"}
if pages != "" {
args = append(args, "-pages", pages)
args = append(args, "--pages", pages)
}
args = append(args, "--", inputPath, strconv.Itoa(angle), inputPath)
@@ -631,10 +631,10 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
}
description := strings.Join(descParts, ", ")
args := []string{command, "add", "-mode", mode}
args := []string{command, "add", "--mode", mode}
if stamp.Pages != "" {
args = append(args, "-pages", stamp.Pages)
args = append(args, "--pages", stamp.Pages)
}
args = append(args, "--", stamp.Expression, description, inputPath, inputPath)

View File

@@ -608,6 +608,50 @@ func FormDataPdfStampFile(form *api.FormData) string {
return path
}
// EnsureStampFile validates that, when stamp.Source is image or pdf, an
// uploaded stamp file was supplied, and replaces stamp.Expression with
// uploadedFile in that case. Returning an [api] HTTP 400 error prevents
// an anonymous caller from passing an arbitrary filesystem path via
// stampExpression and having pdfcpu read it. Source values of text or
// empty are passed through unchanged.
func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error {
if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF {
return nil
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no stamp file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = uploadedFile
return nil
}
// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The
// shape is identical: image or pdf sources must be accompanied by an
// uploaded file, and the file path replaces watermark.Expression to
// prevent pdfcpu from reading an attacker-controlled path.
func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error {
if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF {
return nil
}
if uploadedFile == "" {
return api.WrapError(
errors.New("no watermark file provided for image or pdf source"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
watermark.Expression = uploadedFile
return nil
}
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
// no source, it does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
@@ -676,11 +720,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
@@ -831,11 +877,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
err = EnsureWatermarkFile(&watermark, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
@@ -1268,17 +1316,9 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if watermarkFile == "" {
return api.WrapError(
errors.New("no watermark file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
stamp.Expression = watermarkFile
err = EnsureWatermarkFile(&stamp, watermarkFile)
if err != nil {
return fmt.Errorf("validate watermark: %w", err)
}
err = WatermarkStub(ctx, engine, stamp, inputPaths)
@@ -1319,17 +1359,9 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if stampFile == "" {
return api.WrapError(
errors.New("no stamp file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = stampFile
err = EnsureStampFile(&stamp, stampFile)
if err != nil {
return fmt.Errorf("validate stamp: %w", err)
}
err = StampStub(ctx, engine, stamp, inputPaths)

View File

@@ -127,15 +127,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists, and against the IP-based outbound URL
// guard. See [gotenberg.FilterOutboundURL].
err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline)
// allowed/denied lists, and against the IP-class options.
// See [gotenberg.FilterOutboundURL].
ipOpts := []gotenberg.DecideOption{
gotenberg.WithDenyPrivateIPs(w.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(w.denyPublicIPs),
}
err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter webhook URL: %w", err)
}
if webhookErrorUrl != "" {
err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline)
err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
@@ -198,7 +202,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// Filter the events URL if provided.
if webhookEventsUrl != "" {
err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline)
err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter webhook events URL: %w", err)
}
@@ -220,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
startTime: startTime,
client: &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList),
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...),
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
@@ -334,13 +338,38 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
//
// Echo returns the echo.Context back to its sync.Pool as
// soon as this synchronous handler returns ErrAsyncProcess.
// A concurrent request can then claim the recycled context
// and c.Reset() wipes the shared store, which would cause
// any c.Get("...").(T) assertion downstream of the webhook
// goroutine to panic on a nil value and crash the process.
// Snapshot the keys downstream reads onto a detached
// wrapper before spawning the goroutine so pool reuse
// cannot reach into our async work.
detached := newPoolSafeContext(c, "logger", "context", "correlationId", "correlationIdHeader", "startTime")
w.asyncCount.Add(1)
go func() {
defer cancel()
defer w.asyncCount.Add(-1)
// Defense in depth: any panic that escapes the
// downstream chain (including future regressions of
// the pool-reuse bug) routes through handleError and
// leaves the process running.
defer func() {
r := recover()
if r == nil {
return
}
ctx.Log().Error(fmt.Sprintf("webhook goroutine panic: %v", r))
handleError(fmt.Errorf("internal error: %v", r))
}()
// Call the next middleware in the chain.
err := next(c)
err := next(detached)
if err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)

View File

@@ -0,0 +1,58 @@
package webhook
import (
"sync"
"github.com/labstack/echo/v4"
)
// poolSafeContext wraps an [echo.Context] and keeps a private snapshot of
// the values that downstream middleware and route handlers read from the
// store. Echo returns an [echo.Context] to its sync.Pool as soon as the
// synchronous handler returns, including when the webhook middleware
// returns [api.ErrAsyncProcess]. A concurrent request can then claim the
// recycled context and c.Reset() wipes the shared store out from under
// the webhook goroutine, which causes any
// `c.Get("logger").(*slog.Logger)`-style assertion further down the
// chain to panic on a nil value.
//
// Wrapping c before handing it to the goroutine insulates the async work
// from pool reuse: Get/Set read and write the private store while every
// other [echo.Context] method delegates to the embedded context for
// anything the downstream might still need.
type poolSafeContext struct {
echo.Context
mu sync.RWMutex
store map[string]any
}
// newPoolSafeContext snapshots the given keys from c into a detached
// store and returns a wrapper whose Get/Set operate on that store
// exclusively. Keys absent from c are omitted; the wrapper still
// returns nil for them, matching [echo.Context.Get] behavior.
func newPoolSafeContext(c echo.Context, keys ...string) *poolSafeContext {
store := make(map[string]any, len(keys))
for _, key := range keys {
if v := c.Get(key); v != nil {
store[key] = v
}
}
return &poolSafeContext{Context: c, store: store}
}
// Get returns the value stored in the detached store, not the embedded
// context's pooled store.
func (p *poolSafeContext) Get(key string) any {
p.mu.RLock()
defer p.mu.RUnlock()
return p.store[key]
}
// Set writes to the detached store, not the embedded context's pooled
// store. This prevents downstream middleware writes from leaking into a
// later request that claims the same pooled context.
func (p *poolSafeContext) Set(key string, val any) {
p.mu.Lock()
defer p.mu.Unlock()
p.store[key] = val
}

View File

@@ -0,0 +1,60 @@
package webhook
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
)
func TestPoolSafeContext_SurvivesUnderlyingReset(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
logger := slog.Default()
c.Set("logger", logger)
c.Set("correlationId", "abc-123")
detached := newPoolSafeContext(c, "logger", "correlationId", "missing")
// Simulate Echo recycling c for a concurrent request. Reset wipes the
// shared store, which is exactly the crash scenario the wrapper
// guards against.
c.Reset(httptest.NewRequest(http.MethodGet, "/", nil), httptest.NewRecorder())
if got, _ := detached.Get("logger").(*slog.Logger); got != logger {
t.Fatalf("logger = %v, want snapshotted default logger", got)
}
if got, _ := detached.Get("correlationId").(string); got != "abc-123" {
t.Fatalf("correlationId = %q, want %q", got, "abc-123")
}
if got := detached.Get("missing"); got != nil {
t.Fatalf("missing key returned %v, want nil", got)
}
// Underlying c must remain clean.
if c.Get("logger") != nil {
t.Fatalf("underlying c.Get(\"logger\") leaked wrapper state after reset")
}
}
func TestPoolSafeContext_SetDoesNotTouchUnderlying(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
detached := newPoolSafeContext(c)
detached.Set("foo", "bar")
if got, _ := detached.Get("foo").(string); got != "bar" {
t.Fatalf("detached Get = %q, want bar", got)
}
if c.Get("foo") != nil {
t.Fatalf("Set leaked %q to the underlying pooled context", "foo")
}
}

View File

@@ -23,6 +23,8 @@ type Webhook struct {
denyList []*regexp2.Regexp
errorAllowList []*regexp2.Regexp
errorDenyList []*regexp2.Regexp
denyPrivateIPs bool
denyPublicIPs bool
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
@@ -39,7 +41,9 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature")
fs.StringSlice("webhook-allow-list", []string{}, "Set the allowed URLs for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services")
fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
// Deprecated flags.
@@ -72,6 +76,8 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
w.denyList = flags.MustRegexpSlice("webhook-deny-list")
w.errorAllowList = flags.MustDeprecatedRegexpSlice("webhook-error-allow-list", "webhook-allow-list")
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
w.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")

View File

@@ -195,6 +195,55 @@ Feature: /forms/chromium/convert/html
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitForExpression)
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/paint-callbacks-html/index.html | file |
| waitForExpression | !!document.body.getAttribute('data-pdf-ready') | 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 there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have the following content at page 1:
"""
raf-fired
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
ro-fired
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
io-fired
"""
Scenario: POST /forms/chromium/convert/html (rAF / ResizeObserver / IntersectionObserver fire with waitDelay and emulatedMediaType=print)
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/paint-callbacks-html/index.html | file |
| waitDelay | 3s | field |
| emulatedMediaType | print | 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 there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have the following content at page 1:
"""
raf-fired
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
ro-fired
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
io-fired
"""
Scenario: POST /forms/chromium/convert/html (Wait For Selector)
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):
@@ -1188,6 +1237,30 @@ Feature: /forms/chromium/convert/html
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have 1 image(s)
Scenario: POST /forms/chromium/convert/html (stampSource=pdf without uploaded stamp file => 400)
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/page-1-html/index.html | file |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/chromium/convert/html (watermarkSource=pdf without uploaded watermark file => 400)
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/page-1-html/index.html | file |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/chromium/convert/html (Long Filename)
Given I have a default Gotenberg container

View File

@@ -1153,6 +1153,32 @@ Feature: /forms/chromium/convert/markdown
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/chromium/convert/markdown (stampSource=pdf without uploaded stamp file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/chromium/convert/markdown (watermarkSource=pdf without uploaded watermark file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/chromium/convert/markdown (Long Filename)
Given I have a default Gotenberg container

View File

@@ -478,6 +478,51 @@ Feature: /forms/chromium/convert/url
# Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
| 'file:///etc/passwd' does not match any expression from the allowed list |
Scenario: POST /forms/chromium/convert/url (file:// scheme rejected at route layer)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | file:///tmp/foo/index.html | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML
"""
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, permissive default)
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_ALLOW_LIST | |
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/page-1-html/index.html | field |
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
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):
| 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://127.0.0.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
"""
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):
| CHROMIUM_ALLOW_LIST | .+ |
| CHROMIUM_DENY_PRIVATE_IPS | 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/page-1-html/index.html | field |
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
Scenario: POST /forms/chromium/convert/url (JavaScript Enabled)
Given I have a default Gotenberg container
Given I have a static server
@@ -1234,6 +1279,32 @@ Feature: /forms/chromium/convert/url
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/chromium/convert/url (stampSource=pdf without uploaded stamp file => 400)
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/page-1-html/index.html | field |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/chromium/convert/url (watermarkSource=pdf without uploaded watermark file => 400)
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/page-1-html/index.html | field |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/chromium/convert/url (Long Filename)
Given I have a default Gotenberg container

View File

@@ -44,6 +44,17 @@ Feature: /forms/chromium/screenshot/url
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Scenario: POST /forms/chromium/screenshot/url (file:// scheme rejected at route layer)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/url" endpoint with the following form data and header(s):
| url | file:///tmp/foo/index.html | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML
"""
@webhook
Scenario: POST /forms/chromium/screenshot/url (Webhook)
Given I have a default Gotenberg container

View File

@@ -63,7 +63,9 @@ Feature: /debug
"api-disable-root-route-telemetry": "true",
"api-disable-version-route-telemetry": "true",
"api-download-from-allow-list": "[.+]",
"api-download-from-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
"api-download-from-deny-list": "[]",
"api-download-from-deny-private-ips": "false",
"api-download-from-deny-public-ips": "false",
"api-download-from-max-retry": "4",
"api-enable-basic-auth": "false",
"api-enable-debug-route": "true",
@@ -82,6 +84,8 @@ Feature: /debug
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
"chromium-deny-private-ips": "false",
"chromium-deny-public-ips": "false",
"chromium-disable-javascript": "false",
"chromium-disable-routes": "false",
"chromium-disable-web-security": "false",
@@ -126,7 +130,9 @@ Feature: /debug
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "[.+]",
"webhook-client-timeout": "30s",
"webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
"webhook-deny-list": "[]",
"webhook-deny-private-ips": "false",
"webhook-deny-public-ips": "false",
"webhook-disable": "false",
"webhook-error-allow-list": "[]",
"webhook-error-deny-list": "[]",
@@ -195,7 +201,9 @@ Feature: /debug
"api-disable-root-route-telemetry": "true",
"api-disable-version-route-telemetry": "true",
"api-download-from-allow-list": "[.+]",
"api-download-from-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
"api-download-from-deny-list": "[]",
"api-download-from-deny-private-ips": "false",
"api-download-from-deny-public-ips": "false",
"api-download-from-max-retry": "4",
"api-enable-basic-auth": "false",
"api-enable-debug-route": "true",
@@ -214,6 +222,8 @@ Feature: /debug
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-deny-list": "[^file:(?!//\\/tmp/).*]",
"chromium-deny-private-ips": "false",
"chromium-deny-public-ips": "false",
"chromium-disable-javascript": "false",
"chromium-disable-routes": "false",
"chromium-disable-web-security": "false",
@@ -258,7 +268,9 @@ Feature: /debug
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "[.+]",
"webhook-client-timeout": "30s",
"webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]",
"webhook-deny-list": "[]",
"webhook-deny-private-ips": "false",
"webhook-deny-public-ips": "false",
"webhook-disable": "false",
"webhook-error-allow-list": "[]",
"webhook-error-deny-list": "[]",

View File

@@ -817,6 +817,30 @@ Feature: /forms/libreoffice/convert
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Scenario: POST /forms/libreoffice/convert (stampSource=pdf without uploaded stamp file => 400)
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/page_1.docx | file |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/libreoffice/convert (watermarkSource=pdf without uploaded watermark file => 400)
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/page_1.docx | file |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/libreoffice/convert (Long Filename)
Given I have a default Gotenberg container

View File

@@ -665,6 +665,32 @@ Feature: /forms/pdfengines/merge
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200
Scenario: POST /forms/pdfengines/merge (stampSource=pdf without uploaded stamp file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/merge (watermarkSource=pdf without uploaded watermark file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/merge (Long Filename)
Given I have a default Gotenberg container

View File

@@ -767,6 +767,34 @@ Feature: /forms/pdfengines/split
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200
Scenario: POST /forms/pdfengines/split (stampSource=pdf without uploaded stamp file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| stampSource | pdf | field |
| stampExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/split (watermarkSource=pdf without uploaded watermark file => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| watermarkSource | pdf | field |
| watermarkExpression | /etc/hostname | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
# See: https://github.com/gotenberg/gotenberg/issues/1500.
Scenario: POST /forms/pdfengines/split (Long Filename)
Given I have a default Gotenberg container

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Paint-driven callbacks</title>
<style>
body {
font-family: monospace;
padding: 20px;
}
#target {
width: 100px;
height: 100px;
background: #eee;
}
</style>
</head>
<body>
<p id="raf">raf-pending</p>
<p id="ro">ro-pending</p>
<p id="io">io-pending</p>
<div id="target">target</div>
<script>
requestAnimationFrame(function () {
document.getElementById("raf").textContent = "raf-fired";
});
var target = document.getElementById("target");
new ResizeObserver(function () {
document.getElementById("ro").textContent = "ro-fired";
}).observe(target);
new IntersectionObserver(function () {
document.getElementById("io").textContent = "io-fired";
}).observe(target);
// Signal Gotenberg to print after 2 s. Long enough for rAF / RO / IO
// to have fired when the polyfill is active; short enough to keep
// the test fast.
setTimeout(function () {
document.body.setAttribute("data-pdf-ready", "true");
}, 2000);
</script>
</body>
</html>