Compare commits

..

60 Commits

Author SHA1 Message Date
Julien Neuhart
190cad0ee2 test(integration): surface container logs on failed startup 2026-05-29 15:09:50 +02:00
Julien Neuhart
1d0c9acb2a fix(otel): align resource semconv to v1.41.0 to match SDK default 2026-05-29 15:09:50 +02:00
Julien Neuhart
08181f8550 test(integration): prune orphaned networks to avoid subnet exhaustion 2026-05-29 14:32:50 +02:00
Julien Neuhart
9ea0e82525 chore(deps): update Go dependencies 2026-05-29 10:52:03 +02:00
Julien Neuhart
7967035981 fix(chromium): downgrade pinning-proxy dial logs for client-cancelled requests 2026-05-26 19:39:19 +02:00
Julien Neuhart
3d891edee4 fix(gotenberg): debounce supervisor health probes to absorb transient CDP latency 2026-05-26 19:31:09 +02:00
Julien Neuhart
320ad62e7b docs(README): add FileToPDF.dev sponsor 2026-05-26 19:29:21 +02:00
Julien Neuhart
27e70fde46 fix(chromium): stop pinning proxy when chromedp start fails 2026-05-22 09:44:25 +02:00
Julien Neuhart
2a9bf6bf11 fix(chromium): register lifecycle listeners before navigate to close race 2026-05-22 09:39:25 +02:00
Julien Neuhart
7f9c3e171c fix(chromium): downgrade pinning-proxy logs for client-cancelled requests 2026-05-21 18:30:06 +02:00
Julien Neuhart
13c5b99962 docs(README): switch logo URL [skip ci] 2026-05-12 22:00:01 +02:00
Julien Neuhart
6671b5e5d3 fix(api): serialize downloadFrom result merging to avoid concurrent map writes 2026-05-12 19:25:25 +02:00
Julien Neuhart
f9a01c9fb3 fix(gotenberg): block IPv6 prefixes that tunnel to internal IPv4 in IsPublicIP 2026-05-12 19:22:20 +02:00
dependabot[bot]
26d373854a chore(deps): bump github.com/labstack/echo/v4 from 4.15.1 to 4.15.2
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.1 to 4.15.2.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/v4.15.2/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.1...v4.15.2)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 10:27:54 +02:00
Julien Neuhart
93d0103585 fix(api): strip backslash separators from supplied filenames 2026-05-05 21:14:06 +02:00
Heorhii Ovcharenko
c1cdcbdaab feat(chromium): allow to specify device scale ratio for screenshots (#1543) 2026-05-04 21:16:04 +02:00
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
Julien Neuhart
db51f9026d docs(CONTRIBUTING.md): remove do not push instruction [skip ci] 2026-04-16 17:44:45 +02:00
hubert.lenoir
3187980ead feat: add embeds metadata 2026-04-16 17:28:16 +02:00
dependabot[bot]
eff9444294 chore(deps-dev): bump prettier from 3.8.1 to 3.8.2
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2.
- [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.1...3.8.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 22:30:29 +02:00
Julien Neuhart
999dd2db40 ci: remove thecodingmachine registry 2026-04-14 20:56:47 +02:00
Julien Neuhart
f44d59d514 fix(Dockefile): pin Chromium to version 146 on ppc64le arch 2026-04-13 20:10:47 +02:00
Julien Neuhart
092b526ab5 docs: more succint [skip ci] 2026-04-13 17:59:14 +02:00
Julien Neuhart
0b5eaff8d9 chore(deps): update Go dependencies 2026-04-13 17:45:25 +02:00
Julien Neuhart
46e190970f fix(exiftool): remove System: prefixes 2026-04-13 17:44:58 +02:00
Julien Neuhart
55d19522a8 fix(exiftool): prevent line breaks 2026-04-11 13:11:47 +02:00
Julien Neuhart
924576d3d4 fix(outboundURLs): better detaults 2026-04-11 13:05:05 +02:00
Julien Neuhart
405d8d1c2b chore(deps-dev): update package-lock.json 2026-04-10 13:43:20 +02:00
Julien Neuhart
1b1e100107 chore(deps): update Go dependencies 2026-04-10 13:42:38 +02:00
Julien Neuhart
c3b4702424 Revert "ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10"
This reverts commit 866ae53436.
2026-04-10 13:36:23 +02:00
Julien Neuhart
866ae53436 ci(ppc64le): switch to ubuntu-24.04-ppc64le-p10 2026-04-09 22:00:57 +02:00
Julien Neuhart
53e6a0ecf8 Revert "test(integration): increase timeout to 90min"
This reverts commit f1d1215c7c.
2026-04-07 21:11:52 +02:00
Julien Neuhart
eab3d2da12 chore(deps): update Go dependencies 2026-04-07 21:11:41 +02:00
Julien Neuhart
3f01ca18d3 fix: better denied list 2026-04-07 21:11:41 +02:00
Julien Neuhart
405f1069c0 fix(exitool): prevent control characters 2026-04-07 21:11:41 +02:00
Julien Neuhart
f1d1215c7c test(integration): increase timeout to 90min 2026-04-07 16:21:56 +02:00
81 changed files with 6133 additions and 810 deletions

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
files: @file(../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
@@ -50,6 +50,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -51,6 +51,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -50,6 +50,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -34,6 +34,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -35,6 +35,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -34,6 +34,7 @@ body:multipart-form {
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~deviceScaleFactor: 1.0
}
headers {

View File

@@ -11,7 +11,7 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.docx)
files: @file(../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
@@ -67,6 +67,9 @@ body:multipart-form {
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~embeds: @file(../test/integration/testdata/embed_1.xml)
~embeds: @file(../test/integration/testdata/embed_2.xml)
~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:

View File

@@ -11,8 +11,10 @@ post {
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
embeds: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../test/integration/testdata/page_1.pdf)
embeds: @file(../test/integration/testdata/embed_1.xml)
embeds: @file(../test/integration/testdata/embed_2.xml)
embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}}
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}

View File

@@ -1,24 +1,24 @@
# Bruno API Collection
A [Bruno](https://www.usebruno.com/) collection in `.bruno/` mirrors every Gotenberg route. Update the collection when adding or updating a route.
[Bruno](https://www.usebruno.com/) collection mirroring every Gotenberg route. Update the collection when adding or modifying a route.
## Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, )
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, ...)
```
## `.bru` File Format
## `.bru` file format
```bru
meta {
@@ -51,12 +51,12 @@ headers {
## Conventions
- **Mandatory fields** have no prefix; **optional fields** use the `~` prefix (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** appear on every POST route as optional (`~`).
- **One `.bru` file per request.** For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
- Mandatory fields have no prefix. Optional fields use `~` (disabled by default in Bruno).
- File references use relative paths to `test/integration/testdata/`.
- Webhook and output filename headers appear on every POST route as optional (`~`).
- One `.bru` file per request. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
## Checklist When Adding/Updating a Route
## Checklist
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler. Check `FormData*` calls in the route function.

View File

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

View File

@@ -162,7 +162,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_ppc64le.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
@@ -170,7 +169,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_chromium }},${{ needs.release_386.outputs.tags_chromium }},${{ needs.release_ppc64le.outputs.tags_chromium }},${{ needs.release_arm64.outputs.tags_chromium }},${{ needs.release_arm_v7.outputs.tags_chromium }}"
alternate_registry: thecodingmachine
- name: Merge LibreOffice
uses: ./.github/actions/merge
@@ -178,7 +176,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_libreoffice }},${{ needs.release_386.outputs.tags_libreoffice }},${{ needs.release_ppc64le.outputs.tags_libreoffice }},${{ needs.release_arm64.outputs.tags_libreoffice }},${{ needs.release_arm_v7.outputs.tags_libreoffice }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -186,7 +183,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda }},${{ needs.release_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
@@ -194,7 +190,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.release_arm64.outputs.tags_aws_lambda_chromium }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
@@ -202,7 +197,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.release_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.release_arm64.outputs.tags_aws_lambda_libreoffice }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean

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
@@ -468,7 +468,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_ppc64le.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine
- name: Merge Chromium
uses: ./.github/actions/merge
@@ -476,7 +475,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_chromium }},${{ needs.edge_386.outputs.tags_chromium }},${{ needs.edge_ppc64le.outputs.tags_chromium }},${{ needs.edge_arm64.outputs.tags_chromium }},${{ needs.edge_arm_v7.outputs.tags_chromium }}"
alternate_registry: thecodingmachine
- name: Merge LibreOffice
uses: ./.github/actions/merge
@@ -484,7 +482,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_libreoffice }},${{ needs.edge_386.outputs.tags_libreoffice }},${{ needs.edge_ppc64le.outputs.tags_libreoffice }},${{ needs.edge_arm64.outputs.tags_libreoffice }},${{ needs.edge_arm_v7.outputs.tags_libreoffice }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda
uses: ./.github/actions/merge
@@ -492,7 +489,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda }},${{ needs.edge_arm64.outputs.tags_aws_lambda }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda Chromium
uses: ./.github/actions/merge
@@ -500,7 +496,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_chromium }},${{ needs.edge_arm64.outputs.tags_aws_lambda_chromium }}"
alternate_registry: thecodingmachine
- name: Merge AWS Lambda LibreOffice
uses: ./.github/actions/merge
@@ -508,7 +503,6 @@ jobs:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: "${{ needs.edge_amd64.outputs.tags_aws_lambda_libreoffice }},${{ needs.edge_arm64.outputs.tags_aws_lambda_libreoffice }}"
alternate_registry: thecodingmachine
- name: Clean
uses: ./.github/actions/clean

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,39 +1,59 @@
# Contributing to Gotenberg
**Gotenberg** is a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
Gotenberg is a Docker-based API for converting documents to PDF. Two rules override everything else:
## Getting Started
- **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.
### Prerequisites
## Toolchain
- Go (see version in `go.mod`)
- Module: `github.com/gotenberg/gotenberg/v8`
- Go: see version in `go.mod`
- Docker
- Node.js (see version in `.node-version`), for Prettier linting
- Node.js (see `.node-version`), for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
### Build and Run
## Before you start
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
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.
One thing per PR. Keep features, bug fixes, and refactoring in separate PRs.
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
```
cmd/gotenberg/ -> Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ -> Core module system, interfaces, utilities, mocks.
pkg/modules/ -> Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ -> Wires all standard modules together via imports.
test/integration/ -> Gherkin feature files + Go test infrastructure.
build/ -> Dockerfile, fonts, Chromium config.
.bruno/ -> Bruno API collection (mirrors every route).
```
### Development Loop
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers via `init()`.
```bash
# Write your code, then:
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 # Build the Docker image (required before integration tests)
make test-integration # Run all integration tests
make telemetry # Start OpenTelemetry collector and OpenObserve
make down # Stop all compose containers
```
## Setup and Makefile
To run only the integration tests relevant to your change:
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
@@ -41,117 +61,55 @@ make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Submitting a Pull Request
## Code conventions
For non-trivial changes, outline your approach before writing code. Open an issue or draft PR describing:
### Module system
- What needs to change and why.
- The proposed solution, with enough detail to implement (files to modify, interface changes, form fields, etc.).
- Which integration test tags will be affected and what new scenarios are needed.
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module lives in `pkg/modules/<name>/`, implements at minimum `gotenberg.Module` (`Descriptor()`), and self-registers via `init()`. Wiring happens through `pkg/standard/`.
Before opening (or marking ready) a PR, verify:
Determine if a feature belongs in an existing module before creating a new one. Only create a new module for a genuinely separate concern.
1. Code compiles: `make build`
2. Code is formatted: `make fmt` and `make prettify`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have GoDoc comments
7. Bruno collection is updated (if routes were added or modified)
The `cmd/gotenberg/` package is strictly for wiring and startup. No business logic.
Review your changes against the [Review Checklist](#review-checklist) before submitting.
### Backward compatibility
### Guidelines
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.
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
- **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 in `test/integration/features/`. See [`test/integration/README.md`](test/integration/README.md) for the full reference.
- **Unit tests** when applicable: table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
If a change violates backward compatibility, flag it as a breaking change in the PR description.
### Commit Conventions
### Error handling
If committing, follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- 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.
- Validate input defensively.
```
<type>(<scope>): <description>
```
### Logging
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`).
Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
### Telemetry
---
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.
## Core Principles
### Import ordering
- **Backward compatibility is law.** See the [Review Checklist](#review-checklist) for the full list of what must not change.
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
Enforced by `gci`: standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
## Project Layout and Navigation
## Documentation conventions
```
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks.
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ → Wires all standard modules together via imports.
test/integration/ → Gherkin feature files + Go test infrastructure.
build/ → Dockerfile, fonts, Chromium config.
.bruno/ → Bruno API collection (mirrors every route).
```
### Tone
Key interfaces live in `pkg/gotenberg/`: `Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
- The integration test infrastructure in `test/integration/scenario/` is well-structured. Read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go`. Use them for unit tests rather than creating new ones.
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
- Telemetry infrastructure lives in `pkg/gotenberg/telemetry.go` (global Logger, Tracer, Meter) and `pkg/gotenberg/internal/` (log handlers, OTEL SDK init). HTTP semantic conventions are in `pkg/gotenberg/semconv/`.
## Makefile: the Only Build Interface
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package. The [Development Loop](#development-loop) covers the commands used during daily work. Additional commands:
| Command | Purpose | When to use |
| ---------------- | --------------------------------------------- | ---------------------------------------------------------------------------- |
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags are 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 |
## Module System
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
- Lives in `pkg/modules/<name>/`
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
- Self-registers via `init()` and is wired through `pkg/standard/`
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
## Coding Patterns
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
- **Import ordering:** 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.
- **Logging:** Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
- **Telemetry:** 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 respectively.
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
## Documentation
### Writing Style
- **Short, declarative sentences.** Say what it does, then stop.
- **Lead with the action.** "Validates font embedding" not "This function validates font embedding".
- **Active voice.** "Gotenberg checks the profile" not "The profile is checked by Gotenberg".
- **No em dashes.** Use a period, colon, or comma instead.
- **No "we" hedging.** "Don't..." not "We do not recommend...".
- Short, declarative sentences. Say what it does, then stop.
- Lead with the action. "Validates font embedding", not "This function validates font embedding".
- Active voice. "Gotenberg checks the profile", not "The profile is checked by Gotenberg".
- No em dashes. Use a period, colon, or comma.
- No "we" hedging. "Don't...", not "We do not recommend...".
### Godoc
All exported types and functions require Godoc comments. Start with the identifier name:
Every exported type and function has a Godoc comment starting with its identifier name:
```go
// Violation records a single rule violation with context.
@@ -163,69 +121,58 @@ func ValidatePDFA(ctx context.Context, ...) ([]error, error)
Each package should have a `doc.go` with a `// Package foo ...` comment.
Reference other identifiers with square brackets so pkg.go.dev renders them as links:
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
```go
// ValidatePDFA returns violations as []error where each element is a
// [Violation] value. See [Rule] for the structured rule fields.
// The document must be opened via [pdf.Open] with an [io.ReaderAt].
// ValidatePDFA returns violations as []error where each element
// is a [Violation] value. See [Rule] for the structured fields.
```
This works for same-package identifiers (`[Violation]`), other packages (`[io.Reader]`), and methods (`[Reader.Open]`).
### Code comments
### Code Comments
- Explain _why_, not _what_. The code shows what; the comment explains the non-obvious reasoning.
- Explain _why_, not _what_.
- No numbered step comments (`// 1. Do X`, `// 2. Do Y`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries (`// --- VeraPDF ---`).
- No section dividers with numbers (`// --- 8. Foo ---`). Plain dividers are fine for major boundaries.
- No noise comments that restate the code (`// Check if err is nil`, `// Return results`).
- Reference spec clauses where relevant (`// Per ISO 32000-2, Table 116...`).
- Mark technical debt with `// TODO: [context]`.
- Mark debt with `// TODO: [context]`.
---
## Testing
## Review Checklist
### Unit tests
### Backward Compatibility
Table-driven tests in `*_test.go` files. Use the comprehensive mock implementations in `pkg/gotenberg/mocks.go` rather than rolling new ones.
- [ ] 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()`
### Integration tests
If any of these are violated, the change **must** be flagged as a breaking change.
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.
### Linting Standards
`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.
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, `sloglint`, `gocritic`, and more. Zero linting errors are permitted.
## Pull requests
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` (see import ordering in [Coding Patterns](#coding-patterns)).
### Commits
### Code Quality
[Conventional Commits](https://www.conventionalcommits.org/): `<type>(<scope>): <description>`.
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
- No business logic in `cmd/`.
- No panics in production code paths.
- Input is validated defensively.
- New features belong in the correct module (or justify a new one).
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`).
### Documentation
Stage specific files. Never `git add -A` or `git add .`.
- Every exported function, type, constant, and variable has a Godoc comment starting with its name (see [Godoc](#godoc)).
- New packages include a `doc.go` file.
- `README.md` is not modified unless explicitly requested.
- All documentation follows the [Writing Style](#writing-style) and [Code Comments](#code-comments) guidelines.
### Checklist
---
Before opening the PR, confirm:
## Scoped Guidelines
- [ ] 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.
Some areas of the codebase have their own README with detailed instructions:
## Further reading
| Area | README | Covers |
| ----------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
| Integration tests | [`test/integration/README.md`](test/integration/README.md) | Gherkin step reference, available tags, writing new tests |
| Bruno collection | [`.bruno/README.md`](.bruno/README.md) | `.bru` file format, conventions, route update checklist |
| PDF engines | [`pkg/modules/pdfengines/README.md`](pkg/modules/pdfengines/README.md) | Adding new engine features (Makefile variable and flag) |
- [`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

@@ -27,7 +27,7 @@ API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_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_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
@@ -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=
@@ -76,7 +78,8 @@ PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu
PDFENGINES_EMBED_ENGINES=qpdf,pdfcpu
PDFENGINES_EMBED_METADATA_ENGINES=qpdf
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
@@ -91,9 +94,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
WEBHOOK_ERROR_ALLOW_LIST=
WEBHOOK_ERROR_DENY_LIST=
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_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s

View File

@@ -1,5 +1,5 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<img src="https://raw.githubusercontent.com/gotenberg/art/master/logo.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A Docker-based API for converting documents to PDF</p>
<p align="center">
@@ -64,6 +64,7 @@ If Gotenberg powers your workflow or your business, consider [**becoming a spons
- [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
- [PDFBolt](https://pdfbolt.com)
- [FileToPDF.dev](https://filetopdf.dev)
**Powered By**

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
@@ -202,6 +202,7 @@ FROM common-stage AS gotenberg
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF." \
@@ -211,10 +212,23 @@ LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium \
# Cleanup.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Install LibreOffice & unoconverter.
@@ -271,6 +285,7 @@ FROM common-stage AS gotenberg-chromium
ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001
ARG TMP_CHROMIUM_VERSION_PPC64EL="146.0.7680.80-1~deb13u1"
LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.description="A Docker-based API for converting documents to PDF — Chromium variant." \
@@ -280,10 +295,23 @@ LABEL org.opencontainers.image.title="Gotenberg (Chromium)" \
org.opencontainers.image.source="https://github.com/gotenberg/gotenberg"
# Install Chromium.
RUN apt-get update -qq \
&& apt-get upgrade -yqq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium \
# Cleanup.
# On ppc64el, the latest Chromium is broken, so we pin a known working
# version from snapshot.debian.org via debsnap.
RUN /bin/bash -c \
'set -e &&\
if [[ "$(dpkg --print-architecture)" == "ppc64el" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
debsnap chromium "$TMP_CHROMIUM_VERSION_PPC64EL" -v --force --binary --architecture ppc64el &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" "./binary-chromium/chromium_${TMP_CHROMIUM_VERSION_PPC64EL}_ppc64el.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# COPY instructions last to maximize cache reuse.

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}"
@@ -77,6 +79,7 @@ services:
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}"
- "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}"
- "--pdfengines-embed-metadata-engines=${PDFENGINES_EMBED_METADATA_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--prometheus-namespace=${PROMETHEUS_NAMESPACE}"
- "--prometheus-collect-interval=${PROMETHEUS_COLLECT_INTERVAL}"
@@ -86,8 +89,6 @@ services:
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"

111
go.mod
View File

@@ -1,41 +1,40 @@
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-20260321001828-e3e3800016bc
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/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df
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/echo/v4 v4.15.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.2
github.com/moby/moby/client v0.4.1
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.2
github.com/shirou/gopsutil/v4 v4.26.4
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
golang.org/x/net v0.52.0
github.com/testcontainers/testcontainers-go v0.42.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
)
require (
@@ -43,11 +42,11 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/sevenzip v1.6.4 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -62,11 +61,12 @@ require (
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -75,20 +75,20 @@ require (
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.0 // indirect
github.com/minio/minlz v1.1.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
@@ -96,13 +96,11 @@ require (
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/nwaples/rardecode/v2 v2.2.3 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -112,35 +110,36 @@ require (
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

241
go.sum
View File

@@ -10,18 +10,16 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/alexliesenfeld/health v0.8.1 h1:wdE3vt+cbJotiR8DGDBZPKHDFoJbAoWEfQTcqrmedUg=
github.com/alexliesenfeld/health v0.8.1/go.mod h1:TfNP0f+9WQVWMQRzvMUjlws4ceXKEL3WR+6Hp95HUFc=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
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=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/sevenzip v1.6.4 h1:iHiVJfxbrB6RF4X+snI2MpVgNBKmVfGaTqZGNlMQIU0=
github.com/bodgit/sevenzip v1.6.4/go.mod h1:ZtNi5KNgHXeXg1G7WiF0IWSuFE2eG6lt/cTGlvuirO0=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -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-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/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=
@@ -47,8 +45,8 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
@@ -61,25 +59,23 @@ 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/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
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/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -100,8 +96,8 @@ github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -109,8 +105,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -134,8 +130,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -148,36 +144,38 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
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/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI=
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-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.1.1 h1:OGmft1V6AnI/Wme332U6bhG54nxEan+VFgkD7lat4KM=
github.com/minio/minlz v1.1.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
@@ -186,12 +184,10 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A=
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -200,8 +196,6 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -219,8 +213,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -232,24 +226,25 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stangelandcl/ppmd v0.1.1 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
github.com/stangelandcl/ppmd v0.1.1/go.mod h1:Rrv7M+/2P5jYr/GMLhBl7Ug3uJ1bUiVzr5LbbaV6xgY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.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=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
@@ -263,52 +258,54 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -317,10 +314,10 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -328,23 +325,23 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -355,3 +352,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

22
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.8.1",
"prettier": "3.8.3",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -34,9 +34,9 @@
}
},
"node_modules/@reteps/dockerfmt": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.3.6.tgz",
"integrity": "sha512-Tb5wIMvBf/nLejTQ61krK644/CEMB/cpiaIFXqGApfGqO3GwcR3qnI0DbmkFVCl2OyEp8LnLX3EkucoL0+tbFg==",
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz",
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -58,9 +58,9 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"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": {
@@ -86,13 +86,13 @@
}
},
"node_modules/prettier-plugin-sh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.0.tgz",
"integrity": "sha512-cW1XL27FOJQ/qGHOW6IHwdCiNWQsAgK+feA8V6+xUTaH0cD3Mh+tFAtBvEEWvuY6hTDzRV943Fzeii+qMOh7nQ==",
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz",
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@reteps/dockerfmt": "^0.3.6",
"@reteps/dockerfmt": "^0.5.1",
"sh-syntax": "^0.5.8"
},
"engines": {

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.8.1",
"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

@@ -16,7 +16,7 @@ import (
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
)
// InitTracerProvider initializes the OpenTelemetry tracer provider.

View File

@@ -45,20 +45,21 @@ func (mod *DebuggableMock) Debug() map[string]any {
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
@@ -101,6 +102,10 @@ func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
func (engine *PdfEngineMock) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return engine.EmbedFilesMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}

454
pkg/gotenberg/outbound.go Normal file
View File

@@ -0,0 +1,454 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"github.com/dlclark/regexp2"
)
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
// is not reachable on the public internet. This covers loopback, RFC1918
// private, link-local, unspecified, multicast, and IPv6 unique-local
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
// example [::ffff:127.0.0.1]).
var ErrNonPublicIP = errors.New("non-public IP")
// 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 [resolveHost]. It is a
// package-level variable so that tests can substitute a stub resolver.
var outboundResolver netipResolver = net.DefaultResolver
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
// a package-level variable so that tests can replace it.
var outboundDialer = &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
// not classify via [netip.Addr] helpers but that must not be considered
// public:
//
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
// embed an IPv4 destination, including private ones.
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
// destination, including private ones.
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
// embed an IPv4 destination translated by a NAT64 gateway.
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
// [netip.Addr.IsPrivate] which only handles fc00::/7.
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
// destination and is not handled by [netip.Addr.Unmap].
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
// - 100::/64 Discard prefix (RFC 6666).
var nonPublicIPv6Prefixes = []netip.Prefix{
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("2001::/32"),
netip.MustParsePrefix("64:ff9b::/96"),
netip.MustParsePrefix("64:ff9b:1::/48"),
netip.MustParsePrefix("fec0::/10"),
netip.MustParsePrefix("::/96"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("100::/64"),
}
// IsPublicIP reports whether addr is reachable on the public internet. It
// returns false for loopback, private (RFC1918), link-local, unspecified,
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
// identified as loopback.
//
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
// because a host that routes them implicitly trusts the IPv4 mapping and
// the prefixes themselves are deprecated or translation-only. See
// [nonPublicIPv6Prefixes] for the full list and rationale.
func IsPublicIP(addr netip.Addr) bool {
if !addr.IsValid() {
return false
}
addr = addr.Unmap()
switch {
case addr.IsLoopback(),
addr.IsPrivate(),
addr.IsLinkLocalUnicast(),
addr.IsLinkLocalMulticast(),
addr.IsMulticast(),
addr.IsUnspecified(),
addr.IsInterfaceLocalMulticast():
return false
}
if addr.Is6() {
for _, p := range nonPublicIPv6Prefixes {
if p.Contains(addr) {
return false
}
}
}
return true
}
// 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 err := check(addr); err != nil {
return nil, err
}
return []netip.Addr{addr}, nil
}
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("resolve %q: %w", host, err)
}
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
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 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 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]
// 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 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":
return true
}
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 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)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
normalized := parsed.String()
allowMatched := false
if len(allowList) > 0 {
for _, pattern := range allowList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
allowMatched = true
break
}
}
if !allowMatched {
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
}
}
for _, pattern := range denyList {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return OutboundDecision{}, context.DeadlineExceeded
}
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)
}
}
if allowMatched {
return OutboundDecision{Bypass: true}, nil
}
if !httpLikeScheme(parsed.Scheme) {
return OutboundDecision{}, nil
}
host := parsed.Hostname()
if host == "" {
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
}
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
if err != nil {
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{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.
//
// 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.
type outboundRoundTripper struct {
base http.RoundTripper
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
opts []DecideOption
}
// RoundTrip validates req.URL and delegates to the base transport.
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
deadline, ok := req.Context().Deadline()
if !ok {
deadline = time.Now().Add(30 * time.Second)
}
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
if err != nil {
return nil, err
}
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
return rt.base.RoundTrip(req.WithContext(ctx))
}
// NewOutboundHttpClient returns an [http.Client] that validates every
// 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.
//
// 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{
Timeout: timeout,
Transport: &outboundRoundTripper{
base: base,
allowList: allowList,
denyList: denyList,
opts: opts,
},
}
}
// 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 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 {
return outboundDialer.DialContext(ctx, network, addr)
}
if len(decision.Pinned) > 0 {
return DialPinned(ctx, network, decision.Pinned, port)
}
}
addrs, err := resolveHost(ctx, host, false, false)
if err != nil {
return nil, err
}
return DialPinned(ctx, network, addrs, port)
}
// 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))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr == nil {
return nil, errors.New("no addresses to dial")
}
return nil, lastErr
}

View File

@@ -0,0 +1,489 @@
package gotenberg
import (
"context"
"errors"
"net/netip"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestIsPublicIP(t *testing.T) {
for _, tc := range []struct {
addr string
public bool
}{
// Public.
{"1.1.1.1", true},
{"8.8.8.8", true},
{"2606:4700:4700::1111", true},
// Loopback.
{"127.0.0.1", false},
{"127.255.255.254", false},
{"::1", false},
// IPv4-mapped IPv6 (Issue 2).
{"::ffff:127.0.0.1", false},
{"::ffff:10.0.0.1", false},
{"::ffff:169.254.169.254", false},
// RFC1918.
{"10.0.0.1", false},
{"172.16.0.1", false},
{"172.31.255.254", false},
{"192.168.1.1", false},
// Link-local.
{"169.254.169.254", false},
{"fe80::1", false},
// Unique-local.
{"fc00::1", false},
{"fd12:3456:789a::1", false},
// Unspecified.
{"0.0.0.0", false},
{"::", false},
// Multicast.
{"224.0.0.1", false},
{"ff02::1", false},
// 6to4 wrapping internal/private IPv4 (RFC 3056, deprecated by
// RFC 7526). a9fe:a9fe = 169.254.169.254 (cloud metadata).
{"2002:a9fe:a9fe::", false},
{"2002:0a00:0001::", false},
{"2002:c0a8:0101::", false},
// 6to4 wrapping a public IPv4 (8.8.8.8) is rejected wholesale.
{"2002:0808:0808::", false},
// NAT64 well-known prefix (RFC 6052).
{"64:ff9b::a9fe:a9fe", false},
{"64:ff9b::0808:0808", false},
// NAT64 local-use prefix (RFC 8215).
{"64:ff9b:1::a9fe:a9fe", false},
// Teredo (RFC 4380).
{"2001:0:abcd:ef12:3456:7890:a9fe:a9fe", false},
// Deprecated site-local (RFC 3879).
{"fec0::1", false},
{"feff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", false},
// IPv4-compatible IPv6 (deprecated, not handled by Unmap).
{"::a9fe:a9fe", false},
// Documentation prefix (RFC 3849).
{"2001:db8::1", false},
// Discard prefix (RFC 6666).
{"100::1", false},
} {
t.Run(tc.addr, func(t *testing.T) {
addr, err := netip.ParseAddr(tc.addr)
if err != nil {
t.Fatalf("parse %q: %v", tc.addr, err)
}
if got := IsPublicIP(addr); got != tc.public {
t.Fatalf("IsPublicIP(%q) = %v, want %v", tc.addr, got, tc.public)
}
})
}
}
// stubResolver lets tests fake DNS lookups in [ResolveAndCheckPublic].
type stubResolver struct {
lookup func(host string) ([]netip.Addr, error)
}
func (s stubResolver) LookupNetIP(_ context.Context, _, host string) ([]netip.Addr, error) {
return s.lookup(host)
}
func withStubResolver(t *testing.T, fn func(host string) ([]netip.Addr, error)) {
t.Helper()
prev := outboundResolver
outboundResolver = stubResolver{lookup: fn}
t.Cleanup(func() { outboundResolver = prev })
}
func mustAddrs(t *testing.T, ss ...string) []netip.Addr {
t.Helper()
out := make([]netip.Addr, 0, len(ss))
for _, s := range ss {
a, err := netip.ParseAddr(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
out = append(out, a)
}
return out
}
func TestFilterOutboundURL(t *testing.T) {
defaultDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`, 0),
}
chromiumDeny := []*regexp2.Regexp{
regexp2.MustCompile(`^file:(?!//\/tmp/).*`, 0),
}
for _, tc := range []struct {
scenario string
rawURL string
allow []*regexp2.Regexp
deny []*regexp2.Regexp
opts []DecideOption
stub func(host string) ([]netip.Addr, error)
expectErr bool
expectIs error
expectErrMsg string
}{
{
scenario: "public IP literal passes",
rawURL: "https://1.1.1.1/",
deny: defaultDeny,
expectErr: false,
},
{
scenario: "loopback IP literal blocked by default deny-list",
rawURL: "http://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "Issue 4: uppercase scheme normalized then blocked by deny-list",
rawURL: "HTTP://127.0.0.1:8080/",
deny: defaultDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
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,
},
{
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 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 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 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,
},
{
scenario: "allow-list match bypasses IP check",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
deny: defaultDeny,
opts: []DecideOption{WithDenyPrivateIPs(true)},
expectErr: false,
},
{
scenario: "deny-list still wins over allow-list match",
rawURL: "http://internal.service/api",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal`, 0)},
deny: []*regexp2.Regexp{regexp2.MustCompile(`/api$`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "allow-list non-empty and no match rejects",
rawURL: "https://other.example/",
allow: []*regexp2.Regexp{regexp2.MustCompile(`^https://allowed\.example`, 0)},
expectErr: true,
expectIs: ErrFiltered,
},
{
scenario: "file:// allowed under tmp passes Chromium default",
rawURL: "file:///tmp/index.html",
deny: chromiumDeny,
expectErr: false,
},
{
scenario: "file:// outside tmp blocked by Chromium default",
rawURL: "file:///etc/passwd",
deny: chromiumDeny,
expectErr: true,
expectIs: ErrFiltered,
},
{
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: "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: "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,
},
{
scenario: "data: URL passes (non-network scheme)",
rawURL: "data:text/html;base64,PGgxPmhpPC9oMT4=",
expectErr: false,
},
{
scenario: "URL with no host rejected",
rawURL: "http:///path",
expectErr: true,
expectIs: ErrFiltered,
},
{
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,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.stub != nil {
withStubResolver(t, tc.stub)
} else {
// Default: any DNS lookup in a non-stubbed test is a bug.
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
}
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")
}
if !tc.expectErr && err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if tc.expectIs != nil && !errors.Is(err, tc.expectIs) {
t.Fatalf("expected error to wrap %v, got: %v", tc.expectIs, err)
}
})
}
}
func TestResolveAndCheckPublic_IPLiteralLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
_, err := ResolveAndCheckPublic(context.Background(), "127.0.0.1")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToLoopback(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "127.0.0.1"), nil
})
_, err := ResolveAndCheckPublic(context.Background(), "rebind.example")
if !errors.Is(err, ErrNonPublicIP) {
t.Fatalf("expected ErrNonPublicIP, got: %v", err)
}
}
func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
return mustAddrs(t, "1.1.1.1"), nil
})
addrs, err := ResolveAndCheckPublic(context.Background(), "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(addrs) != 1 || addrs[0].String() != "1.1.1.1" {
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

@@ -201,6 +201,12 @@ type PdfEngine interface {
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// EmbedFilesMetadata sets metadata (such as MIME type and AFRelationship)
// on already-embedded files in a PDF. The metadata map is keyed by
// filename, with each value being a map of property names to values
// (e.g., "mimeType" and "relationship").
EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error

View File

@@ -78,6 +78,23 @@ type ProcessSupervisor interface {
ActiveTasksCount() int64
}
// healthCheckCacheTTL caches successful health probe results so kubelet-
// style probes (liveness + readiness, every few seconds each) do not
// hammer the underlying process with CDP roundtrips on every call.
// Tuned to bridge typical probe periods while still catching outages
// quickly: a real outage surfaces on the next probe after the TTL
// elapses.
const healthCheckCacheTTL = 2 * time.Second
// healthFailureThreshold is the number of consecutive Healthy() failures
// the supervisor tolerates before reporting unhealthy. Absorbs single-
// probe blips of transient CDP latency (for example a slow
// Browser.getVersion roundtrip when several conversion slots are
// simultaneously stuck), without delaying detection of a real outage.
// The container orchestrator's own failureThreshold stacks on top of
// this. See https://github.com/gotenberg/gotenberg/issues/1561.
const healthFailureThreshold = 2
type processSupervisor struct {
logger *slog.Logger
process Process
@@ -86,12 +103,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
@@ -99,9 +117,15 @@ type processSupervisor struct {
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
lastActivity atomic.Int64 // unix nano timestamp of last completed task
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
lastActivity atomic.Int64 // unix nano timestamp of last completed task
// healthMu serializes Healthy() probes so concurrent callers do not
// all issue a CDP roundtrip; the second caller hits the refreshed
// cache instead.
healthMu sync.Mutex
lastHealthyAt atomic.Int64 // unix nano of last successful probe; 0 means never
consecutiveHealthFailures atomic.Int64 // reset to 0 on every successful probe
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
@@ -194,12 +218,52 @@ func (s *processSupervisor) Healthy() bool {
}
if s.isRestarting.Load() {
// A restarting process is not yet healthy — this gives load balancers
// A restarting process is not yet healthy. This gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
}
return s.process.Healthy(s.logger)
// Cache hit: a recent probe succeeded. Skip the CDP roundtrip so probe
// spam does not pile commands onto a busy websocket.
if s.recentlyHealthy() {
return true
}
// Serialize probes so concurrent callers do not all roundtrip. The
// second caller will see the refreshed cache (or counter) and return
// without re-probing.
s.healthMu.Lock()
defer s.healthMu.Unlock()
if s.recentlyHealthy() {
return true
}
if s.process.Healthy(s.logger) {
s.lastHealthyAt.Store(time.Now().UnixNano())
s.consecutiveHealthFailures.Store(0)
return true
}
if s.consecutiveHealthFailures.Add(1) < healthFailureThreshold {
// First failure: tolerate it. Under load, a single blown CDP
// timeout is more likely transient pressure than a dead process.
// A genuinely dead process will fail the next probe as well and
// flip us unhealthy then.
return true
}
return false
}
// recentlyHealthy reports whether a successful probe landed within
// [healthCheckCacheTTL]. Negative results are never cached so recovery
// from a real outage is observable on the very next probe.
func (s *processSupervisor) recentlyHealthy() bool {
last := s.lastHealthyAt.Load()
if last == 0 {
return false
}
return time.Since(time.Unix(0, last)) < healthCheckCacheTTL
}
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
@@ -346,8 +410,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 +437,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

@@ -186,10 +186,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
expectHealthy: true,
},
{
scenario: "process reports as unhealthy",
scenario: "single probe failure is tolerated",
initiallyStarted: true,
processHealthy: false,
expectHealthy: false,
expectHealthy: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
@@ -218,6 +218,109 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
}
}
// TestProcessSupervisor_Healthy_ConsecutiveFailures verifies that only
// the second consecutive process-level failure flips the supervisor to
// unhealthy, and that a single success in between resets the counter.
func TestProcessSupervisor_Healthy_ConsecutiveFailures(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var processHealthy atomic.Bool
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool { return processHealthy.Load() },
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
if !ps.Healthy() {
t.Fatal("first failure should be tolerated and report healthy")
}
if ps.Healthy() {
t.Fatal("second consecutive failure should report unhealthy")
}
processHealthy.Store(true)
if !ps.Healthy() {
t.Fatal("recovery should report healthy immediately")
}
processHealthy.Store(false)
// Cache hit from the previous success absorbs the first new failure;
// invalidate it so we exercise the counter again.
ps.lastHealthyAt.Store(0)
if !ps.Healthy() {
t.Fatal("post-recovery first failure should be tolerated again")
}
if ps.Healthy() {
t.Fatal("post-recovery second consecutive failure should report unhealthy")
}
}
// TestProcessSupervisor_Healthy_CachesPositiveResult verifies that a
// successful probe is cached for [healthCheckCacheTTL] so subsequent
// supervisor.Healthy() calls do not re-issue the underlying process
// check.
func TestProcessSupervisor_Healthy_CachesPositiveResult(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var calls atomic.Int64
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool {
calls.Add(1)
return true
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
for range 5 {
if !ps.Healthy() {
t.Fatal("expected healthy")
}
}
if got := calls.Load(); got != 1 {
t.Fatalf("process.Healthy called %d times, want exactly 1 (cache should absorb the other 4)", got)
}
}
// TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult verifies that
// a probe failure is not cached: the next Healthy() call must re-issue
// the underlying process check so a recovered process surfaces on the
// very next probe.
func TestProcessSupervisor_Healthy_DoesNotCacheNegativeResult(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var calls atomic.Int64
var processHealthy atomic.Bool
process := &ProcessMock{
HealthyMock: func(_ *slog.Logger) bool {
calls.Add(1)
return processHealthy.Load()
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
ps.firstStart.Store(true)
processHealthy.Store(false)
_ = ps.Healthy()
_ = ps.Healthy()
if got := calls.Load(); got != 2 {
t.Fatalf("after two failing probes, process.Healthy called %d times, want 2 (negative results must not be cached)", got)
}
processHealthy.Store(true)
if !ps.Healthy() {
t.Fatal("expected healthy on recovery")
}
if got := calls.Load(); got != 3 {
t.Fatalf("after recovery, process.Healthy called %d times, want 3", got)
}
}
func TestProcessSupervisor_Run(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -898,6 +1001,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].
@@ -197,6 +199,8 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
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{}, "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

@@ -216,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
// Each goroutine writes to its own results slot. The main
// goroutine merges into ctx.files, ctx.diskToOriginal, and
// ctx.filesByField after eg.Wait() to avoid concurrent map
// writes.
type downloadFromResult struct {
filename, path, formField string
}
results := make([]downloadFromResult, len(dls))
eg, _ := errgroup.WithContext(ctx)
for i, dl := range dls {
eg.Go(func() error {
@@ -232,7 +241,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, 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,9 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
}
client := &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: time.Until(deadline),
},
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...),
RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline),
@@ -346,10 +357,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
)
}
// Avoid directory traversal and make sure filename characters are
// normalized.
// Strip path separators (including backslashes) and control
// characters, then NFC-normalize. Defends against directory
// traversal in the on-disk name and Windows-side Zip Slip
// when the original filename is later embedded in an output
// zip entry.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
filename = sanitizeFilename(filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
@@ -387,18 +401,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
dlSpan.SetStatus(codes.Ok, "")
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// Route the downloaded file to the appropriate field bucket.
var formField string
switch {
case dl.Field == "embedded" || dl.Embedded:
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
formField = EmbedsFormField
case dl.Field == "watermark":
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
formField = WatermarkFormField
case dl.Field == "stamp":
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
formField = StampFormField
}
results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
return nil
})
@@ -408,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
if err != nil {
return ctx, cancel, err
}
for _, r := range results {
ctx.files[r.filename] = r.path
ctx.diskToOriginal[r.path] = r.filename
if r.formField != "" {
ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
}
}
}
copyToDisk := func(fh *multipart.FileHeader) error {
@@ -426,10 +446,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
// This will ensure we do not exceed the body limit.
reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
// Avoid directory traversal and make sure filename characters are
// normalized.
// Strip path separators (including backslashes) and control
// characters, then NFC-normalize. Defends against directory
// traversal in the on-disk name and Windows-side Zip Slip when the
// original filename is later embedded in an output zip entry.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
filename := sanitizeFilename(fh.Filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
@@ -467,7 +489,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
}
// Track files by field name
filename := norm.NFC.String(filepath.Base(fh.Filename))
filename := sanitizeFilename(fh.Filename)
filePath := ctx.files[filename]
ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
}
@@ -656,3 +678,21 @@ func (ctx *Context) OutputFilename(outputPath string) string {
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
}
// sanitizeFilename strips path separators (including backslashes, which
// [filepath.Base] ignores on Linux) and control characters from a
// caller-supplied filename, then NFC-normalizes the result. This prevents a
// Windows-side Zip Slip when an output zip is extracted by a permissive
// extractor that interprets '\' as a path separator.
func sanitizeFilename(name string) string {
if i := strings.LastIndexAny(name, `/\`); i >= 0 {
name = name[i+1:]
}
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
return norm.NFC.String(name)
}

View File

@@ -3,10 +3,13 @@ package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
@@ -69,3 +72,137 @@ func TestNewContext_Cancellation(t *testing.T) {
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
}
}
// Concurrent downloadFrom entries must not race on the shared maps
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
// to catch the data race; without -race a sufficient number of entries
// still surfaces "fatal error: concurrent map writes".
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
const downloads = 64
var ready sync.WaitGroup
ready.Add(downloads)
release := make(chan struct{})
var releaseOnce sync.Once
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ready.Done()
go func() {
ready.Wait()
releaseOnce.Do(func() { close(release) })
}()
<-release
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
_, _ = w.Write([]byte("downloaded"))
}))
defer server.Close()
dls := make([]downloadFrom, downloads)
for i := range dls {
dls[i] = downloadFrom{
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
Field: "embedded",
}
}
payload, err := json.Marshal(dls)
if err != nil {
t.Fatalf("marshal downloadFrom payload: %v", err)
}
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err = writer.WriteField("downloadFrom", string(payload))
if err != nil {
t.Fatalf("write downloadFrom field: %v", err)
}
err = writer.Close()
if err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
downloadFromCfg := downloadFromConfig{
maxRetry: 0,
}
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
if err != nil {
t.Fatalf("newContext returned error: %v", err)
}
defer cancel()
if got := len(ctx.files); got != downloads {
t.Fatalf("downloaded files = %d, want %d", got, downloads)
}
if got := len(ctx.diskToOriginal); got != downloads {
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
}
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
}
}
func TestSanitizeFilename(t *testing.T) {
for _, tc := range []struct {
scenario string
input string
expect string
}{
{
scenario: "plain filename is unchanged",
input: "report.pdf",
expect: "report.pdf",
},
{
scenario: "POSIX traversal is stripped",
input: "../../etc/passwd",
expect: "passwd",
},
{
scenario: "Windows traversal with backslashes is stripped",
input: `..\..\..\..\Windows\System32\evil.pdf`,
expect: "evil.pdf",
},
{
scenario: "mixed separators take the last segment",
input: `foo/bar\baz.pdf`,
expect: "baz.pdf",
},
{
scenario: "control characters are dropped",
input: "evil\x00\x07\x1f\x7f.pdf",
expect: "evil.pdf",
},
{
scenario: "NFC normalization collapses decomposed sequences",
// "e" + combining acute accent -> precomposed "é".
input: "café.pdf",
expect: "café.pdf",
},
{
scenario: "trailing backslash yields empty name",
input: `foo\`,
expect: "",
},
{
scenario: "empty input yields empty name",
input: "",
expect: "",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
got := sanitizeFilename(tc.input)
if got != tc.expect {
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
}
})
}
}

View File

@@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"math"
@@ -391,6 +392,38 @@ func (form *FormData) Embeds(target *[]string) *FormData {
return form
}
// EmbedsMetadata parses the "embedsMetadata" form field (a JSON string) into
// a map keyed by filename. Each value is a map of property names to values
// (e.g., "mimeType" and "relationship").
//
// var metadata map[string]map[string]string
//
// ctx.FormData().EmbedsMetadata(&metadata)
func (form *FormData) EmbedsMetadata(target *map[string]map[string]string) *FormData {
if form.errors != nil {
return form
}
val, ok := form.values["embedsMetadata"]
if !ok || len(val) == 0 || val[0] == "" {
return form
}
raw := val[0]
parsed := make(map[string]map[string]string)
err := json.Unmarshal([]byte(raw), &parsed)
if err != nil {
form.append(
fmt.Errorf("form field 'embedsMetadata' is invalid: %w", err),
)
return form
}
*target = parsed
return form
}
// MandatoryPaths binds the absolute paths of form data files, according to a
// list of file extensions, to a string slice variable. It populates an error
// if there is no file for given file extensions.

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))
@@ -146,6 +169,15 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if err != nil {
cancel()
allocatorCancel()
// The pinning proxy started before chromedp; tear it down so a
// supervisor retry can re-bind. Stop is a no-op when the proxy
// was never started (operator-configured --chromium-proxy-server
// or --chromium-host-resolver-rules).
// See https://github.com/gotenberg/gotenberg/issues/1559.
stopErr := b.pinningProxy.Stop(logger)
if stopErr != nil {
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy after failed start: %s", stopErr))
}
return fmt.Errorf("run exec allocator: %w", err)
}
@@ -236,6 +268,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
}
@@ -319,7 +360,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, u
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// Screenshot specific.
setDeviceMetricsOverride(logger, options.Width, options.Height),
setDeviceMetricsOverride(logger, options.Width, options.Height, options.DeviceScaleFactor),
captureScreenshotActionFunc(logger, outputPath, options),
// Teardown.
page.Close(),
@@ -336,8 +377,12 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
return errors.New("context has no deadline")
}
// We validate the "main" URL against our allowed / deny lists.
err := gotenberg.FilterDeadline(b.arguments.allowList, b.arguments.denyList, url, deadline)
// 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,
gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs),
gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs),
)
if err != nil {
return fmt.Errorf("filter URL: %w", err)
}
@@ -358,6 +403,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
}
@@ -347,18 +350,23 @@ type ScreenshotOptions struct {
// OptimizeForSpeed defines whether to optimize image encoding for speed,
// not for resulting size.
OptimizeForSpeed bool
// DeviceScaleFactor is the ratio of the resolution in physical pixels to
// the resolution in CSS pixels for the current display device.
DeviceScaleFactor float64
}
// DefaultScreenshotOptions returns the default values for ScreenshotOptions.
func DefaultScreenshotOptions() ScreenshotOptions {
return ScreenshotOptions{
Options: DefaultOptions(),
Width: 800,
Height: 600,
Clip: false,
Format: "png",
Quality: 100,
OptimizeForSpeed: false,
Options: DefaultOptions(),
Width: 800,
Height: 600,
Clip: false,
Format: "png",
Quality: 100,
OptimizeForSpeed: false,
DeviceScaleFactor: 1.0,
}
}
@@ -447,6 +455,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 +505,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,35 +54,42 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
return
}
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, 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)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
if !allow {
// Use AccessDenied so Chromium emits net::ERR_ACCESS_DENIED,
// which is intentionally absent from the EventLoadingFailed
// known-errors list. Routing through BlockedByClient would
// surface the failure, but the Document-type dispatcher in
// listenForEventLoadingFailed cannot distinguish a blocked
// iframe (sub-frame Document) from a main-page Document, and
// would attribute the iframe failure to the main page.
// Filter-block observability is provided by the warn log
// above instead.
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if err != nil {
@@ -240,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)
@@ -402,19 +428,24 @@ func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, con
})
}
// waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout.
// waitForEventDomContentEventFired registers a listener for the
// DomContentEventFired event and returns a waiter that blocks until the
// event fires or ctx is done. The listener registers at call time, not
// inside the waiter, so callers must invoke this before triggering the
// action that may emit the event. Registering inside the waiter would
// open a race: for fast loads (typically file:// pages with no external
// sub-resources), the event can fire before the waiter goroutine starts
// and the listener never sees a record.
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventDomContentEventFired); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventDomContentEventFired); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event DomContentEventFired fired")
@@ -425,19 +456,20 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger)
}
}
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
// the context timeout.
// waitForEventLoadEventFired registers a listener for the LoadEventFired
// event and returns a waiter that blocks until the event fires or ctx is
// done. See [waitForEventDomContentEventFired] for the rationale on
// registering at call time rather than inside the waiter.
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventLoadEventFired); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*page.EventLoadEventFired); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event LoadEventFired fired")
@@ -448,19 +480,20 @@ func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func()
}
}
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
// context timeout.
// waitForEventNetworkIdle registers a listener for the networkIdle
// lifecycle event and returns a waiter that blocks until the event fires
// or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event networkIdle fired")
@@ -471,19 +504,20 @@ func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() er
}
}
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
// or the context timeout.
// waitForEventNetworkAlmostIdle registers a listener for the networkIdle2
// lifecycle event and returns a waiter that blocks until the event fires
// or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event networkAlmostIdle fired")
@@ -494,19 +528,20 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
}
}
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout.
// waitForEventLoadingFinished registers a listener for the
// LoadingFinished event and returns a waiter that blocks until the event
// fires or ctx is done. See [waitForEventDomContentEventFired] for the
// rationale on registering at call time rather than inside the waiter.
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
cancel()
close(ch)
}
})
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event LoadingFinished fired")

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,366 @@
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 {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT abandoned by client for '%s': %s", req.Host, err))
} else {
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 {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("CONNECT dial abandoned by client for '%s': %s", req.Host, err))
} else {
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 {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("write CONNECT ack abandoned by client: %s", err))
} else {
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 {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward abandoned by client for '%s': %s", req.URL, err))
} else {
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 {
if isClientCancellation(req.Context(), err) {
p.logger.DebugContext(req.Context(), fmt.Sprintf("forward RoundTrip abandoned by client for '%s': %s", req.URL, err))
} else {
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)
}
}
}
// isClientCancellation reports whether err originates from the client (for
// example Chromium) closing the connection or letting the request deadline
// pass before the proxy could finish validating the destination. Such
// errors are not policy refusals: the proxy never reached an allow/deny
// rule decision. Callers downgrade these to debug to avoid alarming
// operators with noise from speculative or aborted browser requests. The
// canonical case is a Chromium DNS prefetch that the browser drops before
// the proxy's [outbound.resolveHost] call returns. The [net.DNSError]
// returned by [net.Resolver.LookupNetIP] unwraps to [context.Canceled] or
// [context.DeadlineExceeded] in that case, so an [errors.Is] walk catches
// it.
func isClientCancellation(ctx context.Context, err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
return ctx.Err() != nil
}

View File

@@ -0,0 +1,900 @@
package chromium
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// recordingHandler is a slog.Handler that captures every record emitted
// through it so tests can assert on the level and message of proxy logs.
type recordingHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *recordingHandler) Enabled(_ context.Context, _ slog.Level) bool { return true }
func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r.Clone())
return nil
}
func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h }
func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h }
func (h *recordingHandler) snapshot() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]slog.Record, len(h.records))
copy(out, h.records)
return out
}
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)
}
}
// TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug verifies that
// when decide fails because the request context was canceled or its
// deadline expired (the canonical case is Chromium dropping a speculative
// CONNECT before the proxy finishes resolving the host), the proxy logs
// at debug and not at warn. Policy refusals must still warn; see
// [TestPinningProxy_CONNECT_BlockedByDecide].
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
// Mimic the wrap chain produced by outbound.resolveHost when the
// DNS lookup is canceled mid-flight by Chromium hanging up.
return gotenberg.OutboundDecision{}, fmt.Errorf("validate '%s' host: resolve %q: lookup %s: %w", "https://www.google.com:443", "www.google.com", "www.google.com", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
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 www.google.com:443 HTTP/1.1\r\nHost: www.google.com: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("status = %d, want 403", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "www.google.com:443") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning www.google.com:443, found none")
}
}
// TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug is the
// handleForward equivalent of
// [TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug]. Plain
// HTTP forward requests aborted by the client must also log at debug.
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://www.google.com/")
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)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "www.google.com") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning www.google.com, found none")
}
}
// TestPinningProxy_PolicyDenial_LoggedAtWarn protects the existing
// behavior: a deny-list match (or any non-cancellation decide error) must
// still surface at warn level so operators see real refusals.
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
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 denied.example:443 HTTP/1.1\r\nHost: denied.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()
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "denied.example") {
continue
}
found = true
if r.Level != slog.LevelWarn {
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "blocked") {
t.Fatalf("message = %q, want it to mention blocked", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning denied.example, found none")
}
}
// TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug verifies that
// when the upstream dial fails because the request context was canceled
// (typically Chromium dropping a speculative CONNECT before a slow IPv6
// dial completes), the proxy logs at debug rather than warn. Genuine
// dial failures must still warn; see
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
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(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
// Mimic a dial canceled mid-flight by the client hanging up,
// which is what net.Dialer returns when ctx.Err() is Canceled.
return nil, fmt.Errorf("dial tcp [2001:4860:482b:7700::]:443: %w", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
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 www.google.com:443 HTTP/1.1\r\nHost: www.google.com: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.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "www.google.com:443") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning CONNECT dial for www.google.com:443, found none")
}
}
// TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn guards the existing
// behavior: a genuine dial failure (host unreachable, refused, etc.)
// must still warn so operators see real problems.
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{}
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(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
return nil, errors.New("connection refused")
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
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 real.example:443 HTTP/1.1\r\nHost: real.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()
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "CONNECT dial") || !strings.Contains(r.Message, "real.example") {
continue
}
found = true
if r.Level != slog.LevelWarn {
t.Fatalf("record level = %v, want Warn; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "failed") {
t.Fatalf("message = %q, want it to mention failed", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning CONNECT dial for real.example, found none")
}
}
// TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug verifies
// the handleForward dial-cancellation path: when the inner Transport's
// dial returns a canceled error (client hung up mid-dial), the proxy
// logs at debug, not warn. Genuine RoundTrip failures still warn.
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{}
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(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
return nil, fmt.Errorf("dial tcp [2001:4860::]:80: %w", context.Canceled)
}
err := p.Start(slog.New(rec))
if err != nil {
t.Fatalf("start pinning proxy: %v", err)
}
t.Cleanup(func() { _ = p.Stop(slog.New(rec)) })
proxyURL := p.URL()
client := &http.Client{
Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))},
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://www.google.com/")
if err != nil {
t.Fatalf("GET via proxy: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("status = %d, want 502", resp.StatusCode)
}
records := rec.snapshot()
var found bool
for _, r := range records {
if !strings.Contains(r.Message, "forward RoundTrip") || !strings.Contains(r.Message, "www.google.com") {
continue
}
found = true
if r.Level != slog.LevelDebug {
t.Fatalf("record level = %v, want Debug; message: %s", r.Level, r.Message)
}
if !strings.Contains(r.Message, "abandoned") {
t.Fatalf("message = %q, want it to mention abandoned", r.Message)
}
}
if !found {
t.Fatal("expected a log record mentioning forward RoundTrip for www.google.com, found none")
}
}
func TestIsClientCancellation(t *testing.T) {
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
for _, tc := range []struct {
name string
ctx context.Context
err error
want bool
}{
{
name: "wrapped context.Canceled",
ctx: context.Background(),
err: fmt.Errorf("validate host: %w", context.Canceled),
want: true,
},
{
name: "wrapped context.DeadlineExceeded",
ctx: context.Background(),
err: fmt.Errorf("validate host: %w", context.DeadlineExceeded),
want: true,
},
{
name: "request context already done",
ctx: canceledCtx,
err: errors.New("some unrelated error"),
want: true,
},
{
name: "policy denial",
ctx: context.Background(),
err: fmt.Errorf("denied: %w", gotenberg.ErrFiltered),
want: false,
},
{
name: "plain dial failure",
ctx: context.Background(),
err: errors.New("connection refused"),
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := isClientCancellation(tc.ctx, tc.err)
if got != tc.want {
t.Fatalf("isClientCancellation = %v, want %v", got, tc.want)
}
})
}
}
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"
@@ -340,11 +341,12 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
defaultScreenshotOptions := DefaultScreenshotOptions()
var (
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
width, height int
clip bool
format string
quality int
optimizeForSpeed bool
deviceScaleFactor float64
)
form.
@@ -387,21 +389,51 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
quality = intValue
return nil
}).
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed)
Bool("optimizeForSpeed", &optimizeForSpeed, defaultScreenshotOptions.OptimizeForSpeed).
Float64("deviceScaleFactor", &deviceScaleFactor, defaultScreenshotOptions.DeviceScaleFactor)
screenshotOptions := ScreenshotOptions{
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
Options: options,
Width: width,
Height: height,
Clip: clip,
Format: format,
Quality: quality,
OptimizeForSpeed: optimizeForSpeed,
DeviceScaleFactor: deviceScaleFactor,
}
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{
@@ -421,6 +453,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
var url string
err := form.
@@ -430,14 +463,21 @@ 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
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
err = rejectFileScheme(url)
if err != nil {
return fmt.Errorf("reject URL scheme: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
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)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -466,6 +506,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)
@@ -496,6 +541,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
var inputPath string
err := form.
@@ -505,16 +551,18 @@ 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)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -575,6 +623,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
var (
inputPath string
@@ -589,11 +638,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)
@@ -602,7 +653,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -727,7 +778,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -831,6 +882,11 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)

View File

@@ -164,11 +164,11 @@ func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options
}
}
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
func setDeviceMetricsOverride(logger *slog.Logger, width, height int, deviceScaleFactor float64) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.DebugContext(ctx, "set device metrics override")
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), deviceScaleFactor, false).Do(ctx)
if err == nil {
return nil
}
@@ -332,11 +332,13 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
return func(ctx context.Context) error {
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
_, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
// Register lifecycle listeners before issuing Page.navigate. For
// fast loads (typically file:// pages with no external
// sub-resources), DomContentEventFired / LoadEventFired /
// LoadingFinished can fire between Navigate.Do returning and
// runBatch spawning the waiter goroutines. Registering ahead of
// the navigate command closes that race.
// See https://github.com/gotenberg/gotenberg/issues/1561.
waitFunc := []func() error{
waitForEventDomContentEventFired(ctx, logger),
waitForEventLoadEventFired(ctx, logger),
@@ -355,6 +357,11 @@ func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, s
logger.DebugContext(ctx, "skipping network almost idle event")
}
_, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
return fmt.Errorf("navigate to '%s': %w", url, err)
}
err = runBatch(
ctx,
waitFunc...,

View File

@@ -2,16 +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"
@@ -23,10 +23,33 @@ func init() {
gotenberg.MustRegisterModule(new(ExifTool))
}
// safeKeyPattern matches legitimate ExifTool tag names: alphanumeric,
// 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 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)
}
return nil
}
// 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
@@ -39,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
@@ -160,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),
@@ -168,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),
@@ -209,101 +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, or link
// creation. Comparison is case-insensitive because ExifTool processes
// tag names case-insensitively.
// 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
}
for key := range metadata {
for _, tag := range dangerousTags {
if strings.EqualFold(key, tag) {
delete(metadata, key)
}
}
}
for key, value := range metadata {
switch val := value.(type) {
case string:
fileMetadata[0].SetString(key, val)
case []string:
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 {
if str, ok := entry.(string); ok {
strs[i] = str
continue
}
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
}
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
@@ -464,6 +518,11 @@ func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPa
return err
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *ExifTool) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)

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

@@ -115,6 +115,11 @@ func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *slog
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)

View File

@@ -37,6 +37,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
embedsMetadata := pdfengines.FormDataPdfEmbedsMetadata(form)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -303,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)
@@ -495,6 +498,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)

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)
@@ -447,6 +447,11 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
return nil
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *PdfCpu) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
@@ -508,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...)
@@ -582,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)
@@ -626,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

@@ -1,14 +1,17 @@
# Adding PDF Engine Features
Each new PDF engine capability (e.g., bookmarks, watermark, stamp, embed) requires a matching Makefile entry. The Makefile variables control which engines are passed to Gotenberg at `make run` and `make test-integration` time (via `compose.yaml`). If you skip this step, the flag still works when set manually, but `make run` falls back to the default defined in `pdfengines.go`, which may not include the new engine.
Each new PDF engine capability (bookmarks, watermark, stamp, embed, etc.) requires a matching Makefile entry. The Makefile variables control which engines are passed to Gotenberg at `make run` and `make test-integration` time via `compose.yaml`. Skip this step and `make run` falls back to the default defined in `pdfengines.go`, which may not include the new engine.
Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a corresponding variable and flag in the Makefile:
Every `--pdfengines-*-engines` flag registered in `pdfengines.go` needs two additions:
1. A variable in the Makefile's variable block (around line 60-70):
1. **Add a variable** in the Makefile's variable block (around line 60 to 70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in `compose.yaml`'s command args:
2. A flag in `compose.yaml`'s command args:
```yaml
- "--pdfengines-<feature>-engines=${PDFENGINES_<FEATURE>_ENGINES}"
```
@@ -17,7 +20,7 @@ The default value must match the `fs.StringSlice(...)` call for that flag in `pd
## Example: Rotate
The rotate feature was added with two engines (`pdfcpu` and `pdftk`). Here is what the additions look like:
Rotate was added with two engines (`pdfcpu` and `pdftk`):
**Makefile** (variable block):

View File

@@ -22,6 +22,7 @@ type multiPdfEngines struct {
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
embedMetadataEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine
@@ -38,6 +39,7 @@ func newMultiPdfEngines(
writeMetadataEngines,
passwordEngines,
embedEngines,
embedMetadataEngines,
readBookmarksEngines,
writeBookmarksEngines,
watermarkEngines,
@@ -53,6 +55,7 @@ func newMultiPdfEngines(
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
embedMetadataEngines: embedMetadataEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines,
@@ -603,6 +606,43 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, i
return err
}
// EmbedFilesMetadata sets metadata on embedded files using the first available
// engine that supports it.
//
//nolint:dupl
func (multi *multiPdfEngines) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
for _, engine := range multi.embedMetadataEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFilesMetadata(ctx, logger, metadata, inputPath)
}(engine)
select {
case setErr := <-errChan:
if setErr != nil {
err = errors.Join(err, setErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
err = fmt.Errorf("set embeds metadata using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.
var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)

View File

@@ -36,6 +36,7 @@ type PdfEngines struct {
writeMetadataNames []string
encryptNames []string
embedNames []string
embedMetadataNames []string
readBookmarksNames []string
writeBookmarksNames []string
watermarkNames []string
@@ -59,6 +60,7 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-embed-metadata-engines", []string{"qpdf"}, "Set the PDF engines and their order for the embed metadata feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
@@ -91,6 +93,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
embedMetadataNames := flags.MustStringSlice("pdfengines-embed-metadata-engines")
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
@@ -162,6 +165,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.embedNames = embedNames
}
mod.embedMetadataNames = defaultNames
if len(embedMetadataNames) > 0 {
mod.embedMetadataNames = embedMetadataNames
}
mod.readBookmarksNames = defaultNames
if len(readBookmarksNames) > 0 {
mod.readBookmarksNames = readBookmarksNames
@@ -236,6 +244,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames)
findNonExistingEngines(mod.embedMetadataNames)
findNonExistingEngines(mod.readBookmarksNames)
findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.watermarkNames)
@@ -261,6 +270,7 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames, " ")),
fmt.Sprintf("embed metadata engines - %s", strings.Join(mod.embedMetadataNames, " ")),
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames, " ")),
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames, " ")),
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
@@ -294,6 +304,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
engines(mod.embedNames),
engines(mod.embedMetadataNames),
engines(mod.readBookmarksNames),
engines(mod.writeBookmarksNames),
engines(mod.watermarkNames),

View File

@@ -443,6 +443,30 @@ func FormDataPdfEmbeds(form *api.FormData) []string {
return embedPaths
}
// FormDataPdfEmbedsMetadata extracts embeds metadata from form data.
// The "embedsMetadata" field is a JSON string keyed by filename.
func FormDataPdfEmbedsMetadata(form *api.FormData) map[string]map[string]string {
var metadata map[string]map[string]string
form.EmbedsMetadata(&metadata)
return metadata
}
// EmbedFilesMetadataStub sets metadata on embedded files in PDFs.
func EmbedFilesMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]map[string]string, inputPaths []string) error {
if len(metadata) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.EmbedFilesMetadata(ctx, ctx.Log(), metadata, inputPath)
if err != nil {
return fmt.Errorf("set embeds metadata on PDF '%s': %w", inputPath, err)
}
}
return nil
}
// FormDataPdfEncrypt extracts encryption parameters from form data.
func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) {
form.String("userPassword", &userPassword, "")
@@ -584,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 {
@@ -638,6 +706,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
var inputPaths []string
var flatten bool
@@ -651,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)
@@ -754,6 +825,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, outputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
@@ -789,6 +865,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
var inputPaths []string
var flatten bool
@@ -800,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)
@@ -856,6 +935,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
@@ -1180,6 +1264,7 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form)
embedsMetadata := FormDataPdfEmbedsMetadata(form)
var inputPaths []string
err := form.
@@ -1193,6 +1278,11 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EmbedFilesMetadataStub(ctx, engine, embedsMetadata, inputPaths)
if err != nil {
return fmt.Errorf("set embeds metadata: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
@@ -1226,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)
@@ -1277,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

@@ -495,6 +495,11 @@ func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath
return nil
}
// EmbedFilesMetadata is not available in this implementation.
func (engine *PdfTk) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
return fmt.Errorf("set embeds metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*PdfTk)(nil)

View File

@@ -3,12 +3,14 @@ package qpdf
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"go.opentelemetry.io/otel/codes"
@@ -348,6 +350,291 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat
return err
}
// EmbedFilesMetadata sets metadata on already-embedded files in a PDF using
// QPDF's JSON manipulation. It sets /AFRelationship on Filespec objects,
// /Subtype on EmbeddedFile streams, and ensures the Catalog /AF array
// references the Filespec objects.
func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if len(metadata) == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
logger.DebugContext(ctx, fmt.Sprintf("setting embeds metadata on %s with QPDF", inputPath))
args := append([]string{inputPath}, engine.globalArgs...)
args = append(args, "--newline-before-endstream", "--json-output")
output, err := engine.execCaptureOutput(ctx, args...)
if err != nil {
err = fmt.Errorf("get PDF JSON with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
objects, err := parsePdfObjects(output)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
catalogRef, catalogValue, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
if len(filespecRefs) == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
patchCatalogAF(catalogRef, catalogValue, filespecRefs, updateObjects)
err = engine.writeAndApplyUpdate(ctx, logger, inputPath, updateObjects)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// execCaptureOutput runs QPDF and returns its stdout. This uses
// exec.CommandContext directly because gotenberg.Cmd does not support
// capturing stdout (it only pipes to debug logs).
func (engine *QPdf) execCaptureOutput(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, engine.binPath, args...) //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return cmd.Output()
}
// parsePdfObjects parses QPDF JSON v2 output and returns the objects map.
func parsePdfObjects(output []byte) (map[string]json.RawMessage, error) {
var pdfJSON struct {
Qpdf []json.RawMessage `json:"qpdf"`
}
if err := json.Unmarshal(output, &pdfJSON); err != nil {
return nil, fmt.Errorf("parse PDF JSON: %w", err)
}
if len(pdfJSON.Qpdf) < 2 {
return nil, fmt.Errorf("unexpected QPDF JSON structure: expected at least 2 elements")
}
var objects map[string]json.RawMessage
if err := json.Unmarshal(pdfJSON.Qpdf[1], &objects); err != nil {
return nil, fmt.Errorf("parse QPDF objects: %w", err)
}
return objects, nil
}
// patchFilespecMetadata walks QPDF objects to find Filespecs matching the
// metadata keys. It sets /AFRelationship and /Subtype on matching objects
// and returns the catalog reference, catalog value, filespec references,
// and the update objects map.
func patchFilespecMetadata(logger *slog.Logger, objects map[string]json.RawMessage, metadata map[string]map[string]string) (string, map[string]any, []string, map[string]any) {
updateObjects := make(map[string]any)
var catalogRef string
var catalogValue map[string]any
var filespecRefs []string
for ref, raw := range objects {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
valueRaw, hasValue := obj["value"]
if !hasValue {
continue
}
var value map[string]any
if err := json.Unmarshal(valueRaw, &value); err != nil {
continue
}
typeVal, _ := value["/Type"].(string)
if typeVal == "/Catalog" {
catalogRef = ref
catalogValue = value
}
if typeVal == "/Filespec" {
uf, _ := value["/UF"].(string)
if uf == "" {
uf, _ = value["/F"].(string)
}
cleanUf := stripQpdfStringPrefix(uf)
meta, exists := metadata[cleanUf]
if !exists {
continue
}
if rel, ok := meta["relationship"]; ok {
value["/AFRelationship"] = "/" + rel
}
if mimeType, ok := meta["mimeType"]; ok {
if ef, ok := value["/EF"].(map[string]any); ok {
efRef, _ := ef["/F"].(string)
if efRef != "" {
setStreamSubtype(logger, objects, updateObjects, efRef, mimeType)
}
}
}
filespecRefs = append(filespecRefs, ref)
updateObjects[ref] = map[string]any{"value": value}
}
}
return catalogRef, catalogValue, filespecRefs, updateObjects
}
// patchCatalogAF ensures the Catalog /AF array references all filespec objects.
func patchCatalogAF(catalogRef string, catalogValue map[string]any, filespecRefs []string, updateObjects map[string]any) {
if catalogRef == "" || catalogValue == nil {
return
}
afSet := make(map[string]bool)
existingAF, _ := catalogValue["/AF"].([]any)
for _, r := range existingAF {
if s, ok := r.(string); ok {
afSet[s] = true
}
}
for _, ref := range filespecRefs {
// Object references in values use "9 0 R" format,
// not the "obj:9 0 R" key format.
valRef := strings.TrimPrefix(ref, "obj:")
if !afSet[valRef] {
existingAF = append(existingAF, valRef)
}
}
catalogValue["/AF"] = existingAF
updateObjects[catalogRef] = map[string]any{"value": catalogValue}
}
// writeAndApplyUpdate marshals the update objects as QPDF JSON v2, writes
// them to a temp file, and applies the update via --update-from-json.
func (engine *QPdf) writeAndApplyUpdate(ctx context.Context, logger *slog.Logger, inputPath string, updateObjects map[string]any) error {
updateJSON := map[string]any{
"qpdf": []any{
map[string]any{
"jsonversion": 2,
"pushedinheritedpageresources": false,
"calledgetallpages": false,
"maxobjectid": 0,
},
updateObjects,
},
}
jsonBytes, err := json.Marshal(updateJSON)
if err != nil {
return fmt.Errorf("marshal update JSON: %w", err)
}
tmpFile, err := os.CreateTemp(filepath.Dir(inputPath), "qpdf-embeds-metadata-*.json")
if err != nil {
return fmt.Errorf("create temp file for update JSON: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.Write(jsonBytes); err != nil {
tmpFile.Close()
return fmt.Errorf("write update JSON: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
updateArgs := make([]string, 0, 5+len(engine.globalArgs))
updateArgs = append(updateArgs, inputPath)
updateArgs = append(updateArgs, engine.globalArgs...)
updateArgs = append(updateArgs, "--newline-before-endstream")
updateArgs = append(updateArgs, "--update-from-json="+tmpFile.Name())
updateArgs = append(updateArgs, "--replace-input")
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, updateArgs...)
if err != nil {
return fmt.Errorf("create command for JSON update: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("update embeds metadata with QPDF: %w", err)
}
return nil
}
// setStreamSubtype finds a stream object by reference and sets the /Subtype
// key in its dict.
func setStreamSubtype(logger *slog.Logger, objects map[string]json.RawMessage, updateObjects map[string]any, ref, mimeType string) {
objKey := ref
if !strings.HasPrefix(objKey, "obj:") {
objKey = "obj:" + objKey
}
raw, ok := objects[objKey]
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: object not found", ref))
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal object: %s", ref, err))
return
}
streamRaw, ok := obj["stream"]
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: no stream key", ref))
return
}
var stream map[string]any
if err := json.Unmarshal(streamRaw, &stream); err != nil {
logger.Warn(fmt.Sprintf("set stream subtype on %s: unmarshal stream: %s", ref, err))
return
}
dict, ok := stream["dict"].(map[string]any)
if !ok {
logger.Warn(fmt.Sprintf("set stream subtype on %s: stream dict is not a map", ref))
return
}
// QPDF JSON uses literal name syntax; it handles PDF name
// encoding internally when writing the binary PDF.
dict["/Subtype"] = "/" + mimeType
stream["dict"] = dict
updateObjects[objKey] = map[string]any{"stream": stream}
}
// stripQpdfStringPrefix removes the type prefix that QPDF adds to JSON
// string values. Known prefixes: "u:" (Unicode), "b:" (binary), "e:" (encoded).
func stripQpdfStringPrefix(s string) string {
for _, prefix := range []string{"u:", "b:", "e:"} {
if strings.HasPrefix(s, prefix) {
return s[len(prefix):]
}
}
return s
}
// Watermark is not available in this implementation.
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",

View File

@@ -0,0 +1,271 @@
package qpdf
import (
"encoding/json"
"log/slog"
"os"
"testing"
)
func TestStripQpdfStringPrefix(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"unicode prefix", "u:factur-x.xml", "factur-x.xml"},
{"binary prefix", "b:binary.bin", "binary.bin"},
{"encoded prefix", "e:encoded.txt", "encoded.txt"},
{"no prefix", "plain.xml", "plain.xml"},
{"empty string", "", ""},
{"prefix only", "u:", ""},
{"colon in value", "u:file:name.xml", "file:name.xml"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripQpdfStringPrefix(tt.input)
if got != tt.expected {
t.Errorf("stripQpdfStringPrefix(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestParsePdfObjects(t *testing.T) {
tests := []struct {
name string
input string
wantKeys []string
wantError bool
}{
{
name: "valid QPDF JSON v2",
input: `{"qpdf":[{"jsonversion":2},{"obj:1 0 R":{"value":{"/Type":"/Catalog"}}}]}`,
wantKeys: []string{"obj:1 0 R"},
},
{
name: "invalid JSON",
input: `not json`,
wantError: true,
},
{
name: "empty qpdf array",
input: `{"qpdf":[]}`,
wantError: true,
},
{
name: "only header element",
input: `{"qpdf":[{"jsonversion":2}]}`,
wantError: true,
},
{
name: "multiple objects",
input: `{"qpdf":[{},{"obj:1 0 R":{"value":{}},"obj:2 0 R":{"value":{}}}]}`,
wantKeys: []string{"obj:1 0 R", "obj:2 0 R"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
objects, err := parsePdfObjects([]byte(tt.input))
if tt.wantError {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, key := range tt.wantKeys {
if _, ok := objects[key]; !ok {
t.Errorf("expected key %q in objects", key)
}
}
})
}
}
func TestPatchFilespecMetadata(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
t.Run("sets AFRelationship on matching Filespec", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Catalog"}}`),
"obj:2 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:factur-x.xml"}}`),
}
metadata := map[string]map[string]string{
"factur-x.xml": {"relationship": "Data"},
}
catalogRef, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
if catalogRef != "obj:1 0 R" {
t.Errorf("catalogRef = %q, want %q", catalogRef, "obj:1 0 R")
}
if len(filespecRefs) != 1 || filespecRefs[0] != "obj:2 0 R" {
t.Errorf("filespecRefs = %v, want [obj:2 0 R]", filespecRefs)
}
updated, ok := updateObjects["obj:2 0 R"]
if !ok {
t.Fatal("expected obj:2 0 R in updateObjects")
}
value := updated.(map[string]any)["value"].(map[string]any)
if value["/AFRelationship"] != "/Data" {
t.Errorf("/AFRelationship = %v, want /Data", value["/AFRelationship"])
}
})
t.Run("skips Filespec with no matching metadata", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:other.xml"}}`),
}
metadata := map[string]map[string]string{
"factur-x.xml": {"relationship": "Data"},
}
_, _, filespecRefs, _ := patchFilespecMetadata(logger, objects, metadata)
if len(filespecRefs) != 0 {
t.Errorf("filespecRefs = %v, want empty", filespecRefs)
}
})
t.Run("falls back to /F when /UF is absent", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:1 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/F":"u:factur-x.xml"}}`),
}
metadata := map[string]map[string]string{
"factur-x.xml": {"relationship": "Alternative"},
}
_, _, filespecRefs, updateObjects := patchFilespecMetadata(logger, objects, metadata)
if len(filespecRefs) != 1 {
t.Fatalf("filespecRefs = %v, want 1 entry", filespecRefs)
}
value := updateObjects["obj:1 0 R"].(map[string]any)["value"].(map[string]any)
if value["/AFRelationship"] != "/Alternative" {
t.Errorf("/AFRelationship = %v, want /Alternative", value["/AFRelationship"])
}
})
t.Run("sets stream Subtype via EF reference", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:2 0 R": json.RawMessage(`{"value":{"/Type":"/Filespec","/UF":"u:factur-x.xml","/EF":{"/F":"3 0 R"}}}`),
"obj:3 0 R": json.RawMessage(`{"stream":{"dict":{"/Type":"/EmbeddedFile"}}}`),
}
metadata := map[string]map[string]string{
"factur-x.xml": {"mimeType": "text/xml"},
}
_, _, _, updateObjects := patchFilespecMetadata(logger, objects, metadata)
streamObj, ok := updateObjects["obj:3 0 R"]
if !ok {
t.Fatal("expected obj:3 0 R in updateObjects")
}
stream := streamObj.(map[string]any)["stream"].(map[string]any)
dict := stream["dict"].(map[string]any)
if dict["/Subtype"] != "/text/xml" {
t.Errorf("/Subtype = %v, want /text/xml", dict["/Subtype"])
}
})
}
func TestPatchCatalogAF(t *testing.T) {
t.Run("adds filespec refs to AF array", func(t *testing.T) {
catalogValue := map[string]any{"/Type": "/Catalog"}
updateObjects := make(map[string]any)
patchCatalogAF("obj:1 0 R", catalogValue, []string{"obj:2 0 R", "obj:3 0 R"}, updateObjects)
af, ok := catalogValue["/AF"].([]any)
if !ok {
t.Fatal("expected /AF to be []any")
}
if len(af) != 2 {
t.Fatalf("/AF has %d entries, want 2", len(af))
}
if af[0] != "2 0 R" || af[1] != "3 0 R" {
t.Errorf("/AF = %v, want [2 0 R, 3 0 R]", af)
}
})
t.Run("does not duplicate existing refs", func(t *testing.T) {
catalogValue := map[string]any{
"/Type": "/Catalog",
"/AF": []any{"2 0 R"},
}
updateObjects := make(map[string]any)
patchCatalogAF("obj:1 0 R", catalogValue, []string{"obj:2 0 R", "obj:3 0 R"}, updateObjects)
af := catalogValue["/AF"].([]any)
if len(af) != 2 {
t.Fatalf("/AF has %d entries, want 2", len(af))
}
})
t.Run("no-op when catalogRef is empty", func(t *testing.T) {
updateObjects := make(map[string]any)
patchCatalogAF("", nil, []string{"obj:2 0 R"}, updateObjects)
if len(updateObjects) != 0 {
t.Error("expected no updates for empty catalogRef")
}
})
}
func TestSetStreamSubtype(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
t.Run("sets Subtype in stream dict", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:3 0 R": json.RawMessage(`{"stream":{"dict":{"/Type":"/EmbeddedFile"}}}`),
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
streamObj := updateObjects["obj:3 0 R"].(map[string]any)["stream"].(map[string]any)
dict := streamObj["dict"].(map[string]any)
if dict["/Subtype"] != "/text/xml" {
t.Errorf("/Subtype = %v, want /text/xml", dict["/Subtype"])
}
})
t.Run("auto-adds obj: prefix to ref", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:5 0 R": json.RawMessage(`{"stream":{"dict":{}}}`),
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "5 0 R", "application/pdf")
if _, ok := updateObjects["obj:5 0 R"]; !ok {
t.Error("expected obj:5 0 R in updateObjects")
}
})
t.Run("warns on missing object", func(t *testing.T) {
objects := map[string]json.RawMessage{}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:99 0 R", "text/xml")
if len(updateObjects) != 0 {
t.Error("expected no updates for missing object")
}
})
t.Run("warns on object without stream key", func(t *testing.T) {
objects := map[string]json.RawMessage{
"obj:3 0 R": json.RawMessage(`{"value":{"/Type":"/Page"}}`),
}
updateObjects := make(map[string]any)
setStreamSubtype(logger, objects, updateObjects, "obj:3 0 R", "text/xml")
if len(updateObjects) != 0 {
t.Error("expected no updates for non-stream object")
}
})
}

View File

@@ -127,14 +127,19 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists.
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, 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.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
@@ -197,7 +202,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// Filter the events URL if provided.
if webhookEventsUrl != "" {
err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline)
err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline, ipOpts...)
if err != nil {
return fmt.Errorf("filter webhook events URL: %w", err)
}
@@ -219,9 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
startTime: startTime,
client: &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: w.clientTimeout,
},
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...),
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
@@ -335,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
@@ -40,9 +42,21 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
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{}, "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.
fs.StringSlice("webhook-error-allow-list", []string{}, "Set the allowed URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-error-deny-list", []string{}, "Set the denied URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
err := fs.MarkDeprecated("webhook-error-allow-list", "use --webhook-allow-list instead")
if err != nil {
panic(err)
}
err = fs.MarkDeprecated("webhook-error-deny-list", "use --webhook-deny-list instead")
if err != nil {
panic(err)
}
fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
fs.Duration("webhook-client-timeout", time.Duration(30)*time.Second, "Set the time limit for requests to the webhook")
@@ -60,8 +74,10 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode")
w.allowList = flags.MustRegexpSlice("webhook-allow-list")
w.denyList = flags.MustRegexpSlice("webhook-deny-list")
w.errorAllowList = flags.MustRegexpSlice("webhook-error-allow-list")
w.errorDenyList = flags.MustRegexpSlice("webhook-error-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

@@ -1,60 +1,52 @@
# Integration Tests
- **Framework:** Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
- **Feature files:** `test/integration/features/*.feature`, one file per endpoint or capability.
- **Test infrastructure:** `test/integration/scenario/` contains Go step definitions, container management, HTTP helpers, and PDF validation.
- **Entry point:** `test/integration/main_test.go` (build tag: `integration`).
- **Test data:** `test/integration/testdata/`
Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
## How It Works
- Feature files: `test/integration/features/*.feature`, one per endpoint or capability.
- Step definitions: `test/integration/scenario/` (container management, HTTP helpers, PDF validation).
- Entry point: `test/integration/main_test.go` (build tag: `integration`).
- Test data: `test/integration/testdata/`.
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. Step definitions in `scenario/scenario.go` map Gherkin steps to Go functions. A separate `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. A separate `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
**Important:** Run `make build` before `make test-integration`. Integration tests require a Docker image.
Run `make build` before `make test-integration`. Integration tests require a Docker image.
## Selective Test Runs
Use the `TAGS` variable to run only relevant scenarios:
## Selective runs
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
make test-integration NO_CONCURRENCY=true # disable parallel scenarios
make test-integration PLATFORM=linux/arm64 # force a specific platform
```
Available tags:
| Group | Tags |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
| LibreOffice | `libreoffice`, `libreoffice-convert` |
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
| Infrastructure | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
| Group | Tags |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Chromium | `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `chromium-screenshot-html`, `chromium-screenshot-markdown`, `chromium-screenshot-url` |
| LibreOffice | `libreoffice`, `libreoffice-convert` |
| PDF Engines | `pdfengines`, `pdfengines-convert`, `pdfengines-merge`, `merge`, `pdfengines-split`, `split`, `pdfengines-flatten`, `flatten`, `pdfengines-rotate`, `rotate`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-metadata`, `metadata`, `pdfengines-bookmarks`, `bookmarks` |
| Infra | `health`, `debug`, `root`, `version`, `output-filename`, `prometheus-metrics`, `webhook`, `download-from` |
Other useful flags:
```bash
make test-integration NO_CONCURRENCY=true # Disable parallel scenarios
make test-integration PLATFORM=linux/arm64 # Force a specific platform
```
## Writing a New Integration Test
## Writing a new test
1. Create or update a `.feature` file in `test/integration/features/`.
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
3. For new tags, add them to both the `TAGS` comment block in the `Makefile` and the "Available tags" list above.
4. For new step definitions, add the function to `scenario/scenario.go`, register it in `InitializeScenario`, and add the step pattern to the "Available Gherkin Steps" list below (follow the existing format: backtick-quoted pattern, then parenthetical notes on arguments).
3. For new tags, add them to both the `TAGS` comment block in the `Makefile` and the table above.
4. For new step definitions, add the function to `scenario/scenario.go`, register it in `InitializeScenario`, and add the step pattern to the step reference below.
5. Test data goes in `test/integration/testdata/`.
## Available Gherkin Steps
## Step reference
**Given (setup):**
### Given (setup)
- `I have a default Gotenberg container`
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
- `I have a (webhook|static) server`
**When (action):**
### When (action)
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
@@ -62,14 +54,14 @@ make test-integration PLATFORM=linux/arm64 # Force a specific platform
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
- `I wait for the asynchronous request to the webhook`
**Then (assertions):**
### Then (assertions)
- `the response status code should be <code>`
- `the (response|webhook request|file request|server request) header "<name>" should be "<value>"`
- `the (response|webhook request|file request|server request) cookie "<name>" should be "<value>"`
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring, use `"ignore"` for dynamic values like timestamps)
- `the (response|webhook request) body should match JSON:` (docstring, use `"ignore"` for dynamic values)
- `the webhook event should match JSON:` (docstring, use `"ignore"` for dynamic values; polls for up to 5s)
- `there should be <N> PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)

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

@@ -72,6 +72,15 @@ Feature: /forms/chromium/screenshot/html
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
Scenario: POST /forms/chromium/screenshot/html (Device Scale Factor)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| deviceScaleFactor | 1.0 | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "image/png"
Scenario: POST /forms/chromium/screenshot/html (Omit Background)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/html" endpoint with the following form data and header(s):

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

@@ -62,8 +62,10 @@ Feature: /debug
"api-disable-health-check-route-telemetry": "true",
"api-disable-root-route-telemetry": "true",
"api-disable-version-route-telemetry": "true",
"api-download-from-allow-list": "[]",
"api-download-from-allow-list": "[.+]",
"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",
@@ -77,11 +79,13 @@ Feature: /debug
"api-trace-header": "Gotenberg-Trace",
"chromium-allow-file-access-from-files": "false",
"chromium-allow-insecure-localhost": "false",
"chromium-allow-list": "[]",
"chromium-allow-list": "[.+]",
"chromium-auto-start": "false",
"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",
@@ -124,9 +128,11 @@ Feature: /debug
"prometheus-disable-route-telemetry": "true",
"prometheus-namespace": "gotenberg",
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "[]",
"webhook-allow-list": "[.+]",
"webhook-client-timeout": "30s",
"webhook-deny-list": "[]",
"webhook-deny-private-ips": "false",
"webhook-deny-public-ips": "false",
"webhook-disable": "false",
"webhook-error-allow-list": "[]",
"webhook-error-deny-list": "[]",
@@ -194,8 +200,10 @@ Feature: /debug
"api-disable-health-check-route-telemetry": "true",
"api-disable-root-route-telemetry": "true",
"api-disable-version-route-telemetry": "true",
"api-download-from-allow-list": "[]",
"api-download-from-allow-list": "[.+]",
"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",
@@ -209,11 +217,13 @@ Feature: /debug
"api-trace-header": "Gotenberg-Trace",
"chromium-allow-file-access-from-files": "false",
"chromium-allow-insecure-localhost": "false",
"chromium-allow-list": "[]",
"chromium-allow-list": "[.+]",
"chromium-auto-start": "false",
"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",
@@ -256,9 +266,11 @@ Feature: /debug
"prometheus-disable-route-telemetry": "true",
"prometheus-namespace": "gotenberg",
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "[]",
"webhook-allow-list": "[.+]",
"webhook-client-timeout": "30s",
"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

@@ -17,6 +17,21 @@ Feature: /forms/pdfengines/embed
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
Scenario: POST /forms/pdfengines/embed with metadata
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| embedsMetadata | {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"},"embed_2.xml":{"mimeType":"text/xml","relationship":"Alternative"}} | field |
Then the response status code should be 200
And the response header "Content-Type" should be "application/pdf"
And there should be 1 PDF(s) in the response
And the response PDF(s) should have the "embed_1.xml" file embedded
And the response PDF(s) should have the "embed_1.xml" file embedded with relationship "Data"
And the response PDF(s) should have the "embed_2.xml" file embedded
And the response PDF(s) should have the "embed_2.xml" file embedded with relationship "Alternative"
@download-from
Scenario: POST /forms/pdfengines/embed with (Download From)
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

@@ -106,6 +106,43 @@ Feature: /forms/pdfengines/metadata/{write|read}
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/pdfengines/metadata/write (Reject Newline-Injected Pseudo-Tag)
# Regression: a newline in a metadata value would split go-exiftool's
# stdin line and inject an arbitrary ExifTool pseudo-tag such as
# -FileName=, -SymLink=, or -HardLink=, allowing arbitrary filesystem
# writes as the container user. WriteMetadata now rejects values
# containing control characters with HTTP 400.
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| metadata | {"Title":"test\\n-FileName=/tmp/inject_proof"} | 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 contain string:
"""
At least one PDF engine cannot process the requested metadata
"""
Scenario: POST /forms/pdfengines/metadata/write (Reject Group-Prefixed Dangerous Tag)
# Regression: ExifTool treats "System:FileName" identically to "FileName".
# The dangerous-tag blocklist must strip group prefixes before comparing,
# otherwise the attacker renames/moves files with a single HTTP request.
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| metadata | {"System:FileName":"stolen.pdf","System:Directory":"/tmp","Author":"legit"} | 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
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response body should contain string:
"""
"Author":"legit"
"""
Scenario: POST /forms/pdfengines/metadata/read (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):

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

@@ -3,6 +3,7 @@
package integration
import (
"context"
"fmt"
"os"
"runtime"
@@ -48,6 +49,14 @@ func TestMain(m *testing.M) {
// Reset the failure collector before each run.
scenario.ResetFailedScenarios()
// Reclaim subnets leaked by prior runs or failed container starts
// before they exhaust Docker's predefined address pools.
if deleted, err := scenario.PruneOrphanedNetworks(context.Background()); err != nil {
fmt.Fprintf(colors.Colored(os.Stdout), "warning: prune orphaned networks: %v\n", err)
} else if deleted > 0 {
fmt.Fprintf(colors.Colored(os.Stdout), "pruned %d orphaned network(s)\n", deleted)
}
code := godog.TestSuite{
Name: "integration",
ScenarioInitializer: scenario.InitializeScenario,

View File

@@ -7,13 +7,45 @@ import (
"path/filepath"
"time"
"github.com/docker/docker/api/types/container"
"github.com/docker/go-connections/nat"
"github.com/moby/moby/api/types/container"
"github.com/moby/moby/client"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/exec"
"github.com/testcontainers/testcontainers-go/network"
"github.com/testcontainers/testcontainers-go/wait"
)
// testcontainersLabel is the label testcontainers-go stamps on every
// container and network it creates. Pruning on this label only ever touches
// resources owned by the test suite.
const testcontainersLabel = "org.testcontainers"
// PruneOrphanedNetworks removes dangling networks created by the test suite.
// Each scenario spins a dedicated network, and a failed container start can
// leak one before teardown records it. Leaked networks consume Docker's
// predefined address pools until none remain and every later scenario fails
// with "all predefined address pools have been fully subnetted". Call this
// before a run and between retries to reclaim the subnets.
//
// Only unused networks bearing the testcontainers label are removed, so
// running containers and operator networks are never affected.
func PruneOrphanedNetworks(ctx context.Context) (int, error) {
cli, err := client.New(client.FromEnv)
if err != nil {
return 0, fmt.Errorf("create Docker client: %w", err)
}
defer cli.Close()
filters := client.Filters{}.Add("label", testcontainersLabel+"=true")
report, err := cli.NetworkPrune(ctx, client.NetworkPruneOptions{Filters: filters})
if err != nil {
return 0, fmt.Errorf("prune networks: %w", err)
}
return len(report.Report.NetworksDeleted), nil
}
var (
GotenbergDockerRepository string
GotenbergVersion string
@@ -26,10 +58,53 @@ func (n *noopLogger) Printf(format string, v ...any) {
// NOOP
}
// integrationAllowList is the default allow-list pattern injected into
// every Gotenberg container started by the integration tests. The outbound
// URL guard introduced for SSRF protection rejects URLs whose host
// resolves to a non-public IP, which would block:
//
// - host.docker.internal (Docker host gateway, RFC1918)
// - The static helper server running inside the test network
// - file:// URIs created in /tmp by the API context
//
// Setting the allow-list to a permissive pattern flips the URL guard into
// "allow-list match bypasses the IP check" mode for every URL the tests
// touch. Operator-supplied deny-lists still apply, so deny-list scenarios
// keep working. Test scenarios that exercise allow-list semantics
// explicitly override this default in their environment table.
//
// Production operators wanting a similar bypass for trusted internal
// destinations should set their own --*-allow-list with a tighter regex
// (for example ^https?://internal\.svc(:|/|$)).
const integrationAllowList = `.+`
// applyDefaultEnv merges baseline environment variables that the
// integration tests rely on into env, without overwriting values supplied
// by the test scenario itself. Tests can clear a default by setting it to
// the empty string in their scenario table.
func applyDefaultEnv(env map[string]string) map[string]string {
if env == nil {
env = make(map[string]string)
}
defaults := map[string]string{
"CHROMIUM_ALLOW_LIST": integrationAllowList,
"API_DOWNLOAD_FROM_ALLOW_LIST": integrationAllowList,
"WEBHOOK_ALLOW_LIST": integrationAllowList,
}
for k, v := range defaults {
if _, ok := env[k]; !ok {
env[k] = v
}
}
return env
}
func startGotenbergContainer(ctx context.Context, env map[string]string) (*testcontainers.DockerNetwork, testcontainers.Container, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
env = applyDefaultEnv(env)
n, err := network.New(ctx)
if err != nil {
return nil, nil, fmt.Errorf("create Gotenberg container network: %w", err)
@@ -59,9 +134,33 @@ func startGotenbergContainer(ctx context.Context, env map[string]string) (*testc
})
if err != nil {
err = fmt.Errorf("start new Gotenberg container: %w", err)
// A failed start (for example the process exiting before the health
// check passes) only reports an opaque exit code. testcontainers
// still returns the container on this path, so attach its logs to
// surface the actual crash reason in CI.
if c != nil {
if logs, errLogs := containerLogEntries(ctx, c); errLogs == nil && logs != "" {
err = fmt.Errorf("%w\ncontainer logs:\n%s", err, logs)
}
if errTerminate := c.Terminate(ctx, testcontainers.StopTimeout(0)); errTerminate != nil {
err = fmt.Errorf("%w (also failed to terminate container: %v)", err, errTerminate)
}
}
// The network is already created. The scenario teardown only
// removes networks it knows about, and the caller discards n on
// error, so remove it here to avoid leaking a subnet on every
// failed start. Leaked networks accumulate until Docker's address
// pools are fully subnetted and all later scenarios fail.
if errRemove := n.Remove(ctx); errRemove != nil {
err = fmt.Errorf("%w (also failed to remove network: %v)", err, errRemove)
}
return nil, nil, err
}
return n, c, err
return n, c, nil
}
func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, path string) (string, error) {
@@ -96,7 +195,7 @@ func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, p
}
}(c, ctx)
_, output, err := c.Exec(ctx, cmd)
_, output, err := c.Exec(ctx, cmd, exec.Multiplexed())
if err != nil {
return "", fmt.Errorf("exec %q: %w", cmd, err)
}
@@ -109,7 +208,7 @@ func execCommandInIntegrationToolsContainer(ctx context.Context, cmd []string, p
return string(b), nil
}
func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port nat.Port) (string, error) {
func containerHttpEndpoint(ctx context.Context, container testcontainers.Container, port string) (string, error) {
ip, err := container.Host(ctx)
if err != nil {
return "", fmt.Errorf("get container IP: %w", err)

View File

@@ -1262,6 +1262,68 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, shou
return nil
}
func (s *scenario) thePdfsShouldHaveEmbeddedFileWithRelationship(ctx context.Context, kind, embed, relationship string) error {
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", dirPath, err)
}
for _, path := range paths {
cmd := []string{
"verapdf",
"--off",
"--loglevel",
"0",
"--extract",
"embeddedFile",
filepath.Base(path),
}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
fileNameTag := fmt.Sprintf("<fileName>%s</fileName>", embed)
relationshipTag := fmt.Sprintf("<afRelationship>%s</afRelationship>", relationship)
blocks := strings.Split(output, "</embeddedFile>")
found := false
for _, block := range blocks {
if !strings.Contains(block, fileNameTag) {
continue
}
if !strings.Contains(block, relationshipTag) {
return fmt.Errorf("embedded file %q missing afRelationship %q", embed, relationship)
}
found = true
break
}
if !found {
return fmt.Errorf("embedded file %q not found in verapdf output", embed)
}
}
return nil
}
func InitializeScenario(ctx *godog.ScenarioContext) {
s := &scenario{}
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
@@ -1300,6 +1362,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted)
ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile)
ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)

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>