Compare commits

..

36 Commits

Author SHA1 Message Date
Julien Neuhart
9ec7be4687 docs: use real identifiers in the Godoc examples 2026-08-07 19:56:54 +02:00
Julien Neuhart
de7f335791 fix(outbound): keep dial pinning for hops the environment proxy declines 2026-08-07 16:37:03 +02:00
Julien Neuhart
815f586315 fix(chromium): bound the total scope matching time per conversion 2026-08-07 16:29:55 +02:00
Julien Neuhart
b71df026f6 fix(api): sanitize the output filename header 2026-08-07 16:22:42 +02:00
Julien Neuhart
8d29638b74 fix(pdfcpu): pass --force when writing over the input file 2026-08-07 15:49:53 +02:00
Julien Neuhart
63c9a36599 chore(deps): update unoconverter to v0.4.0 2026-08-07 15:40:42 +02:00
Julien Neuhart
31fa392db2 fix(libreoffice)!: return 500 when a failure is not the client's fault 2026-08-07 15:33:44 +02:00
Julien Neuhart
bb0b874d16 fix(telemetry): align resource semconv with otel sdk 1.45 detectors 2026-08-07 14:04:37 +02:00
Julien Neuhart
60f5a7b996 chore(deps): update go version in go.mod 2026-08-07 13:57:14 +02:00
Effy Teva
8b559eb699 chore(deps): update Golang and pdfcpu version 2026-08-07 13:47:41 +02:00
dependabot[bot]
50e8e44bc1 chore(deps): bump go.opentelemetry.io/otel/sdk from 1.44.0 to 1.45.0
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:46:53 +02:00
dependabot[bot]
b243283c9b chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.6 to 4.26.7
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.6 to 4.26.7.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.6...v4.26.7)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:46:22 +02:00
dependabot[bot]
a0d5e93ced chore(deps): bump github.com/cucumber/godog from 0.15.1 to 0.16.0
Bumps [github.com/cucumber/godog](https://github.com/cucumber/godog) from 0.15.1 to 0.16.0.
- [Release notes](https://github.com/cucumber/godog/releases)
- [Changelog](https://github.com/cucumber/godog/blob/main/CHANGELOG.md)
- [Commits](https://github.com/cucumber/godog/compare/v0.15.1...v0.16.0)

---
updated-dependencies:
- dependency-name: github.com/cucumber/godog
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:44:30 +02:00
dependabot[bot]
7568fc379b chore(deps): bump go.opentelemetry.io/otel from 1.44.0 to 1.45.0
Bumps [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.45.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 13:43:48 +02:00
dependabot[bot]
5b21dbf1a9 chore(deps): bump github.com/moby/moby/client from 0.5.0 to 0.5.1
Bumps [github.com/moby/moby/client](https://github.com/moby/moby) from 0.5.0 to 0.5.1.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.5.1/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.5.0...v0.5.1)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/client
  dependency-version: 0.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:33:37 +02:00
dependabot[bot]
0a62359691 chore(deps-dev): bump prettier from 3.9.5 to 3.9.6
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.5 to 3.9.6.
- [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.9.5...3.9.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:32:02 +02:00
dependabot[bot]
7418b5cab3 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.24.0 to 1.24.1.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.24.0...v1.24.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 09:31:41 +02:00
dependabot[bot]
337a5cca64 chore(deps): bump google.golang.org/grpc from 1.81.1 to 1.82.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.81.1 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.81.1...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:33:58 +02:00
dependabot[bot]
387fce2cf6 chore(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.2 to 1.24.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.0/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:32:26 +02:00
dependabot[bot]
c89991cc0d chore(deps-dev): bump prettier-plugin-sh from 0.18.1 to 0.19.0
Bumps [prettier-plugin-sh](https://github.com/un-ts/prettier) from 0.18.1 to 0.19.0.
- [Release notes](https://github.com/un-ts/prettier/releases)
- [Changelog](https://github.com/un-ts/prettier/blob/master/CHANGELOG.md)
- [Commits](https://github.com/un-ts/prettier/compare/prettier-plugin-sh@0.18.1...prettier-plugin-sh@0.19.0)

---
updated-dependencies:
- dependency-name: prettier-plugin-sh
  dependency-version: 0.19.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 09:32:18 +02:00
dependabot[bot]
0c8d681c35 chore(deps): bump actions/setup-go from 6 to 7
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:44:40 +02:00
dependabot[bot]
3d300c2a09 chore(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:44:29 +02:00
dependabot[bot]
8aa5a4f0e7 chore(deps-dev): bump prettier from 3.9.4 to 3.9.5
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.4 to 3.9.5.
- [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.9.4...3.9.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-17 16:27:45 +02:00
Julien Neuhart
a92a7fedba feat(outbound): support authenticated proxy from environment variables 2026-07-15 20:08:29 +02:00
Julien Neuhart
d0e3991d16 fix(chromium): serialize browser starts to prevent pinning proxy latch after a start timeout 2026-07-15 19:04:38 +02:00
dependabot[bot]
d67ef724f9 chore(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:08:40 +02:00
dependabot[bot]
68a0a9e5c1 chore(deps): bump golang.org/x/text from 0.38.0 to 0.40.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.38.0 to 0.40.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:05:55 +02:00
dependabot[bot]
b5f77c3b73 chore(deps-dev): bump prettier from 3.8.4 to 3.9.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.4 to 3.9.4.
- [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.4...3.9.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-13 10:05:42 +02:00
dependabot[bot]
be5dcf943d chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.5 to 4.26.6
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.5 to 4.26.6.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.5...v4.26.6)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 08:48:29 +02:00
dependabot[bot]
9ca1e302bd chore(deps): bump github.com/testcontainers/testcontainers-go
Bumps [github.com/testcontainers/testcontainers-go](https://github.com/testcontainers/testcontainers-go) from 0.42.0 to 0.43.0.
- [Release notes](https://github.com/testcontainers/testcontainers-go/releases)
- [Commits](https://github.com/testcontainers/testcontainers-go/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: github.com/testcontainers/testcontainers-go
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 10:54:03 +02:00
dependabot[bot]
3617bc6587 chore(deps): bump github.com/moby/moby/client from 0.4.1 to 0.5.0
Bumps [github.com/moby/moby/client](https://github.com/moby/moby) from 0.4.1 to 0.5.0.
- [Release notes](https://github.com/moby/moby/releases)
- [Changelog](https://github.com/moby/moby/blob/v0.5.0/CHANGELOG.md)
- [Commits](https://github.com/moby/moby/compare/v0.4.1...v0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:17:04 +02:00
dependabot[bot]
874adf076c chore(deps): bump github.com/moby/moby/api from 1.54.2 to 1.55.0
Bumps [github.com/moby/moby/api](https://github.com/moby/moby) from 1.54.2 to 1.55.0.
- [Release notes](https://github.com/moby/moby/releases)
- [Commits](https://github.com/moby/moby/compare/api/v1.54.2...api/v1.55.0)

---
updated-dependencies:
- dependency-name: github.com/moby/moby/api
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:15:11 +02:00
dependabot[bot]
9d83255ecd chore(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 09:14:56 +02:00
dependabot[bot]
3b43bfbf33 chore(deps-dev): bump prettier from 3.8.3 to 3.8.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.3 to 3.8.4.
- [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.3...3.8.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 17:55:55 +02:00
Julien Neuhart
7614964109 chore(deps): update Go dependencies, keep chromedp pinned to v0.14.2 2026-06-16 17:40:44 +02:00
Julien Neuhart
7b054da4e7 fix(chromium): generateDocumentOutline now implies generateTaggedPdf 2026-06-16 17:06:10 +02:00
49 changed files with 2310 additions and 284 deletions

View File

@@ -12,6 +12,12 @@ updates:
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "weekly"
ignore:
# Held at v0.14.2: v0.15.x breaks the headless print-mode paint pipeline
# (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts).
# See https://github.com/gotenberg/gotenberg/issues/1535.
- dependency-name: "github.com/chromedp/chromedp"
- dependency-name: "github.com/chromedp/cdproto"
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: "/" directory: "/"
schedule: schedule:

View File

@@ -23,7 +23,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -50,7 +50,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -77,7 +77,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -104,7 +104,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -131,7 +131,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build and push - name: Build and push
id: build_push id: build_push
@@ -154,7 +154,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge

View File

@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
@@ -38,10 +38,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version-file: .node-version node-version-file: .node-version
@@ -59,10 +59,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
@@ -87,7 +87,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -117,7 +117,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -147,7 +147,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -177,7 +177,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -207,7 +207,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -253,7 +253,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge
@@ -322,7 +322,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -351,7 +351,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -380,7 +380,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -409,7 +409,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -438,7 +438,7 @@ jobs:
tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }} tags_aws_lambda_libreoffice: ${{ steps.build_test_push.outputs.tags_aws_lambda_libreoffice }}
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Build, test and push - name: Build, test and push
id: build_test_push id: build_test_push
@@ -460,7 +460,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout source code - name: Checkout source code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Merge - name: Merge
uses: ./.github/actions/merge uses: ./.github/actions/merge

View File

@@ -14,7 +14,7 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Cleanup - name: Cleanup
uses: ./.github/actions/clean uses: ./.github/actions/clean

View File

@@ -121,20 +121,28 @@ Enforced by `gci`: standard library, then third-party, then `github.com/gotenber
Every exported type and function has a Godoc comment starting with its identifier name: Every exported type and function has a Godoc comment starting with its identifier name:
```go ```go
// Violation records a single rule violation with context. // OutboundDecision is the result of validating an outbound URL via
type Violation struct { ... } // [DecideOutbound]. ...
type OutboundDecision struct { ... }
// ValidatePDFA audits the document against a PDF/A profile. // DialPinned dials each addr in turn until one connects, returning the
func ValidatePDFA(ctx context.Context, ...) ([]error, error) // first successful connection or the last error. ...
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
``` ```
Each package should have a `doc.go` with a `// Package foo ...` comment. Each package should have a `doc.go` with a `// Package foo ...` comment:
```go
// Package api manages a LibreOffice instance via the UNO API.
package api
```
Reference identifiers with `[Name]` brackets for pkg.go.dev linking: Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
```go ```go
// ValidatePDFA returns violations as []error where each element // Callers pass the Pinned slice from [OutboundDecision] so that the dial
// is a [Violation] value. See [Rule] for the structured fields. // targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
// rebinding between validation and connect.
``` ```
### Code comments ### Code comments

View File

@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_BIND_IP= API_BIND_IP=
API_TLS_CERT_FILE=
API_TLS_KEY_FILE=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
@@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_LIST= API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd) API_DOWNLOAD_FROM_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS=false
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false
API_DOWNLOAD_FROM_MAX_RETRY=4 API_DOWNLOAD_FROM_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
@@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES= CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER= CHROMIUM_PROXY_SERVER=
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
CHROMIUM_ALLOW_LIST= CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_DENY_PRIVATE_IPS=false
CHROMIUM_DENY_PUBLIC_IPS=false
CHROMIUM_CLEAR_CACHE=false CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_JAVASCRIPT=false
@@ -60,6 +68,9 @@ LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST= LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST= LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DENY_PRIVATE_IPS=false
LIBREOFFICE_DENY_PUBLIC_IPS=false
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
LIBREOFFICE_DISABLE_ROUTES=false LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
@@ -97,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST= WEBHOOK_ALLOW_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_DENY_LIST=^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
WEBHOOK_DENY_PRIVATE_IPS=false
WEBHOOK_DENY_PUBLIC_IPS=false
WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false
WEBHOOK_MAX_RETRY=4 WEBHOOK_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s WEBHOOK_RETRY_MAX_WAIT=30s

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will # ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build # concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them. # stage that uses them.
ARG GOLANG_VERSION=1.26.2 ARG GOLANG_VERSION=1.26.5
# ---------------------------------------------- # ----------------------------------------------
# pdfcpu binary build stage # pdfcpu binary build stage
@@ -11,7 +11,7 @@ ARG GOLANG_VERSION=1.26.2
FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage FROM golang:$GOLANG_VERSION AS pdfcpu-binary-stage
# See https://github.com/pdfcpu/pdfcpu/releases. # See https://github.com/pdfcpu/pdfcpu/releases.
ARG PDFCPU_VERSION=v0.12.0 ARG PDFCPU_VERSION=v0.13.0
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
# Define the working directory outside of $GOPATH (we're using go modules). # Define the working directory outside of $GOPATH (we're using go modules).
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
WORKDIR /downloads WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \ RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
&& chmod +x unoconverter && chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \ RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \

View File

@@ -21,6 +21,8 @@ services:
- "--api-port=${API_PORT}" - "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}" - "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}" - "--api-bind-ip=${API_BIND_IP}"
- "--api-tls-cert-file=${API_TLS_CERT_FILE}"
- "--api-tls-key-file=${API_TLS_KEY_FILE}"
- "--api-start-timeout=${API_START_TIMEOUT}" - "--api-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}" - "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}" - "--api-body-limit=${API_BODY_LIMIT}"
@@ -29,6 +31,9 @@ services:
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}" - "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}" - "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}" - "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}"
- "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}"
- "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}" - "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}" - "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}" - "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
@@ -48,8 +53,11 @@ services:
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}" - "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}" - "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}" - "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}" - "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}" - "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
- "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}"
- "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}"
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}" - "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}" - "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}" - "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
@@ -61,6 +69,9 @@ services:
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}" - "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}" - "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}" - "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}"
- "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}"
- "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}"
- "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}" - "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}" - "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}" - "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
@@ -91,6 +102,9 @@ services:
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}" - "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}" - "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}" - "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}"
- "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}"
- "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}" - "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}" - "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}" - "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"

78
go.mod
View File

@@ -1,40 +1,40 @@
module github.com/gotenberg/gotenberg/v8 module github.com/gotenberg/gotenberg/v8
go 1.26.2 go 1.26.5
require ( require (
github.com/alexliesenfeld/health v0.8.1 github.com/alexliesenfeld/health v0.8.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // pinned with chromedp v0.14.2, see below
github.com/chromedp/chromedp v0.14.2 github.com/chromedp/chromedp v0.14.2 // pinned: v0.15.x breaks the headless print-mode paint pipeline (rAF / ResizeObserver / IntersectionObserver stop firing, blank charts). See https://github.com/gotenberg/gotenberg/issues/1535.
github.com/cucumber/godog v0.15.1 github.com/cucumber/godog v0.16.0
github.com/dlclark/regexp2 v1.12.0 github.com/dlclark/regexp2 v1.12.0
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.15.2 github.com/labstack/echo/v4 v4.15.4
github.com/labstack/gommon v0.5.0 github.com/labstack/gommon v0.5.0
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/moby/moby/api v1.54.2 github.com/moby/moby/api v1.55.0
github.com/moby/moby/client v0.4.1 github.com/moby/moby/client v0.5.1
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.24.1
github.com/shirou/gopsutil/v4 v4.26.5 github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go v0.43.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel v1.45.0
go.opentelemetry.io/otel/log v0.20.0 go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/metric v1.45.0
go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/sdk/log v0.20.0 go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0 go.opentelemetry.io/otel/sdk/metric v1.45.0
go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/otel/trace v1.45.0
golang.org/x/net v0.55.0 golang.org/x/net v0.57.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.22.0
golang.org/x/term v0.43.0 golang.org/x/term v0.45.0
golang.org/x/text v0.37.0 golang.org/x/text v0.40.0
) )
require ( require (
@@ -57,23 +57,22 @@ require (
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect github.com/cucumber/gherkin/go/v42 v42.0.0 // indirect
github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/cucumber/messages/go/v34 v34.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.10.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 // indirect github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect 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/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.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-cleanhttp v0.5.2 // indirect
@@ -81,32 +80,32 @@ require (
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magiconair/properties v1.8.10 // indirect github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.1 // indirect github.com/minio/minlz v1.1.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/sequential v0.7.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect github.com/moby/term v0.5.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.3 // indirect github.com/nwaples/rardecode/v2 v2.2.5 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
@@ -132,14 +131,13 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.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/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.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 go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/grpc v1.81.1 // indirect google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

161
go.sum
View File

@@ -44,16 +44,14 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= 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/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.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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/v42 v42.0.0 h1:Ulh3E2awUUSSja+wonP/IOQ+ycmiZwZbgmzqk5H8JNI=
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= github.com/cucumber/gherkin/go/v42 v42.0.0/go.mod h1:CsaumaO2dR9XvBc6ZyiGLMhWCKtTRDxgoxqJigSjSSg=
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE=
github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM=
github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8=
github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8=
github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -68,17 +66,17 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= 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/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/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.2/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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 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.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA= github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6 h1:nxP4pPoyqOAgX8lYDFCfl3DyKeXErCvSvhcyzwGV9CE=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-json-experiment/json v0.0.0-20260601182631-00ed12fed2a6/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.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.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -90,14 +88,10 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df h1:Mwihr/o+v4L5h56rwHLOE20+hh7Okhwno5BHz3zDuao= github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76 h1:Ltt9ldIaSYEsjA7sPY2c8r9dOmnKM1vlzhh3dxlhBHM=
github.com/gomarkdown/markdown v0.0.0-20260417124207-7d523f7318df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20260614204949-e08cff860f76/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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -111,10 +105,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 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= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg=
github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
@@ -128,24 +120,20 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 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 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.15.2 h1:nnh2sCzGCVYnU+wCisMPiYapEg/QVo/gcI9ePKg5/T4= github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs=
github.com/labstack/echo/v4 v4.15.2/go.mod h1:Xzp1Ns1RA2c9fY7nSgUJkpkUZGNbEIVHZbtbOMPktBI= github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g=
github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c=
github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= 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 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
@@ -154,8 +142,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= 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/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 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
@@ -170,14 +158,14 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 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 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= 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.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
@@ -186,44 +174,40 @@ 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/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A= github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY=
github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/nwaples/rardecode/v2 v2.2.5/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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 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.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 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/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.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:c25QazhlWUn5nmR1QOzafKhQxBicAr7GGCKER2aJ8H8=
@@ -236,11 +220,10 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= 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/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 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
@@ -266,8 +249,8 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0ax
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU= 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 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= 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.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
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 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/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 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
@@ -292,20 +275,20 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= 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 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= 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.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= 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 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 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= 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.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -314,34 +297,34 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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 h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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-20260615183401-62b3387ff324 h1:g0RAkxK/smSu/iRwC/KIX1mwUoVJtk2OjbgaeS4DmUM=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

107
package-lock.json generated
View File

@@ -5,9 +5,9 @@
"packages": { "packages": {
"": { "": {
"devDependencies": { "devDependencies": {
"prettier": "3.8.3", "prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3", "prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0" "prettier-plugin-sh": "^0.19.0"
} }
}, },
"node_modules/@cucumber/gherkin": { "node_modules/@cucumber/gherkin": {
@@ -34,15 +34,80 @@
} }
}, },
"node_modules/@reteps/dockerfmt": { "node_modules/@reteps/dockerfmt": {
"version": "0.5.2", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@reteps/dockerfmt/-/dockerfmt-0.5.4.tgz",
"integrity": "sha512-Hbr7yen4fP5TxGM54ucXa4o5NwWXatJ6Bd9I8gp0PValYbI4Rug2Gu+rVv7K7o/efQc3F5ctqWJz47rYaa8zBw==", "integrity": "sha512-HEGgXVVOb+JtGUSSzXl/XPKFIZjMDTUoHarCjaQdkY+cb5M9K/O3b5xm+x0IPIk3SfHurbc0bSgcFsQlzjitxA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": {
"dockerfmt": "dist/launcher.js"
},
"engines": { "engines": {
"node": "^v12.20.0 || ^14.13.0 || >=16.0.0" "node": "^v12.20.0 || ^14.13.0 || >=16.0.0"
},
"optionalDependencies": {
"@reteps/dockerfmt-darwin-arm64": "0.5.4",
"@reteps/dockerfmt-darwin-x64": "0.5.4",
"@reteps/dockerfmt-linux-arm64": "0.5.4",
"@reteps/dockerfmt-linux-x64": "0.5.4"
} }
}, },
"node_modules/@reteps/dockerfmt-darwin-arm64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-arm64/-/dockerfmt-darwin-arm64-0.5.4.tgz",
"integrity": "sha512-urMqV+dQyvVI8/WrXwClX9e1PEyS35wFdwJjpZYmL09AkV4Io5U1oam8UBKK7jZk0+YsdF88ay6e86Kn6DIyQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@reteps/dockerfmt-darwin-x64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-darwin-x64/-/dockerfmt-darwin-x64-0.5.4.tgz",
"integrity": "sha512-fJORy6DFxbgDiMqxpLTPZlb5KUY0Vq0iR4NGnyKnuYZ9LdZUS508DK2kt/AJ87/jIKNV1qRG0JXG1Tc6xdvWjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@reteps/dockerfmt-linux-arm64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-arm64/-/dockerfmt-linux-arm64-0.5.4.tgz",
"integrity": "sha512-6pVakO06eXtDuvxy1Dnjs/gQyUoGGycle8PRSt5IFRwLi/AVaOQwfkfmW0WP8VH9wNUeti6BfJ3ksTn2G+XMxg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@reteps/dockerfmt-linux-x64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/@reteps/dockerfmt-linux-x64/-/dockerfmt-linux-x64-0.5.4.tgz",
"integrity": "sha512-OD6SIlUV1D4TgJoTui3FMBAZsGbTSPYsiT0BKhD6jMUcJb3GpFTa7dY9rL8rP9FUqfL7OTHVUGUOL4Rh64Olog==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@types/uuid": { "node_modules/@types/uuid": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
@@ -58,9 +123,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.8.3", "version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@@ -86,14 +151,14 @@
} }
}, },
"node_modules/prettier-plugin-sh": { "node_modules/prettier-plugin-sh": {
"version": "0.18.1", "version": "0.19.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.18.1.tgz", "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz",
"integrity": "sha512-uZmU22wBMevjh3rmCatNQqiEer2+5KLa0xYCBX6zQQUQkcNzVL+s6FbPKK6ZSUNUbQk6jMAcQHrYPvuL2W6ihQ==", "integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@reteps/dockerfmt": "^0.5.1", "@reteps/dockerfmt": "^0.5.4",
"sh-syntax": "^0.5.8" "sh-syntax": "^0.6.0"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -113,14 +178,11 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/sh-syntax": { "node_modules/sh-syntax": {
"version": "0.5.8", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.5.8.tgz", "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz",
"integrity": "sha512-JfVoxf4FxQI5qpsPbkHhZo+n6N9YMJobyl4oGEUBb/31oQYlgTjkXQD8PBiafS2UbWoxrTO0Z5PJUBXEPAG1Zw==", "integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
},
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
@@ -128,13 +190,6 @@
"url": "https://opencollective.com/sh-syntax" "url": "https://opencollective.com/sh-syntax"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"prettier": "3.8.3", "prettier": "3.9.6",
"prettier-plugin-gherkin": "^3.1.3", "prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0" "prettier-plugin-sh": "^0.19.0"
} }
} }

View File

@@ -17,13 +17,18 @@ import (
"go.opentelemetry.io/otel/sdk/metric/exemplar" "go.opentelemetry.io/otel/sdk/metric/exemplar"
"go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0" semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
) )
// buildResource assembles the OpenTelemetry resource shared by the tracer, // buildResource assembles the OpenTelemetry resource shared by the tracer,
// meter, and logger providers. Detection is best-effort: a detector or merge // meter, and logger providers. Detection is best-effort: a detector or merge
// failure is logged and the build proceeds with whatever was gathered, so a // failure is logged and the build proceeds with whatever was gathered, so a
// flaky environment never prevents telemetry from starting. // flaky environment never prevents telemetry from starting.
//
// The semconv version imported here must match the one the SDK resource
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
// schema URL off every exported signal.
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource { func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
base := resource.NewWithAttributes( base := resource.NewWithAttributes(
semconv.SchemaURL, semconv.SchemaURL,
@@ -55,9 +60,14 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
return base return base
} }
// A schema URL conflict still yields a resource holding every attribute, only
// without a schema URL. Keep it: falling back to base would drop the host,
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
merged, err := resource.Merge(detected, base) merged, err := resource.Merge(detected, base)
if err != nil { if err != nil {
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err)) logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
}
if merged == nil {
return base return base
} }

View File

@@ -11,7 +11,7 @@ import (
"go.opentelemetry.io/otel/sdk/metric/exemplar" "go.opentelemetry.io/otel/sdk/metric/exemplar"
"go.opentelemetry.io/otel/sdk/metric/metricdata" "go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace" sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.41.0" semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
) )
func TestBuildResource(t *testing.T) { func TestBuildResource(t *testing.T) {
@@ -24,6 +24,13 @@ func TestBuildResource(t *testing.T) {
values[string(kv.Key)] = kv.Value.AsString() values[string(kv.Key)] = kv.Value.AsString()
} }
// Guards the semconv version pinned in buildResource against the one the SDK
// resource detectors use. Drift makes resource.Merge conflict and drops the
// schema URL from every exported signal.
if res.SchemaURL() != semconv.SchemaURL {
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
}
if values[string(semconv.ServiceNameKey)] != "gotenberg" { if values[string(semconv.ServiceNameKey)] != "gotenberg" {
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg") t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
} }

View File

@@ -1,17 +1,22 @@
package gotenberg package gotenberg
import ( import (
"bufio"
"context" "context"
"crypto/tls"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
) )
// ErrNonPublicIP indicates that an outbound URL targets an IP address that // ErrNonPublicIP indicates that an outbound URL targets an IP address that
@@ -183,6 +188,12 @@ type OutboundDecision struct {
// is stored. // is stored.
type outboundDecisionKey struct{} type outboundDecisionKey struct{}
// outboundProxiedKey is the context key under which [outboundRoundTripper]
// records that the environment proxy will carry this request, so that the
// dialer knows the address it receives is the proxy's rather than the
// destination's.
type outboundProxiedKey struct{}
// decideConfig carries optional settings for [DecideOutbound] and // decideConfig carries optional settings for [DecideOutbound] and
// [FilterOutboundURL]. See [DecideOption] for how callers configure it. // [FilterOutboundURL]. See [DecideOption] for how callers configure it.
type decideConfig struct { type decideConfig struct {
@@ -360,6 +371,10 @@ type outboundRoundTripper struct {
allowList []*regexp2.Regexp allowList []*regexp2.Regexp
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
opts []DecideOption opts []DecideOption
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
// the environment proxy is enabled.
proxyFunc func(*url.URL) (*url.URL, error)
} }
// RoundTrip validates req.URL and delegates to the base transport. // RoundTrip validates req.URL and delegates to the base transport.
@@ -375,6 +390,18 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
} }
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision) ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
// A request the proxy will not carry is dialed directly, so it still gets
// pinned. Without this, enabling the environment proxy would silently drop
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
// when no proxy variable is set at all.
if rt.proxyFunc != nil {
proxyURL, proxyErr := rt.proxyFunc(req.URL)
if proxyErr == nil && proxyURL != nil {
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
}
}
return rt.base.RoundTrip(req.WithContext(ctx)) return rt.base.RoundTrip(req.WithContext(ctx))
} }
@@ -389,9 +416,47 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
// //
// The default posture is permissive; callers pass [WithDenyPrivateIPs] // The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection. // or [WithDenyPublicIPs] to opt into IP-class rejection.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client { //
// When enableEnvironmentProxy is true, the client routes through the proxy
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
// including any credentials embedded in those URLs. Dial pinning does not apply
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
// pinned. The URL allow/deny and IP-class validation runs either way. Callers
// gate this behind their module's opt-in flag. See
// https://github.com/gotenberg/gotenberg/issues/1592.
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone() base := http.DefaultTransport.(*http.Transport).Clone()
base.DialContext = secureDialContext
var proxyFunc func(*url.URL) (*url.URL, error)
if enableEnvironmentProxy {
// Route through the operator's proxy (standard env vars, credentials
// included). httpproxy.FromEnvironment reads the environment now rather
// than caching it process-wide like http.ProxyFromEnvironment.
proxyFunc = httpproxy.FromEnvironment().ProxyFunc()
base.Proxy = func(req *http.Request) (*url.URL, error) {
return proxyFunc(req.URL)
}
// Only a hop the proxy actually carries skips pinning: there the dial
// targets the proxy, not the destination, and the proxy owns DNS. A hop
// the proxy declines is dialed directly and stays pinned.
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
return outboundDialer.DialContext(ctx, network, addr)
}
return secureDialContext(ctx, network, addr)
}
} else {
// Default: ignore any proxy environment variables and pin the dial to
// the IPs resolved during validation, closing the DNS-rebinding
// window. Clearing Proxy is deliberate: the cloned default transport
// carries http.ProxyFromEnvironment, which combined with the pinned
// dialer would connect to the destination IP on the proxy's port.
base.Proxy = nil
base.DialContext = secureDialContext
}
return &http.Client{ return &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: &outboundRoundTripper{ Transport: &outboundRoundTripper{
@@ -399,10 +464,60 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
allowList: allowList, allowList: allowList,
denyList: denyList, denyList: denyList,
opts: opts, opts: opts,
proxyFunc: proxyFunc,
}, },
} }
} }
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
// reads, in the casing precedence it applies.
var environmentProxyVariables = []string{
"HTTP_PROXY", "http_proxy",
"HTTPS_PROXY", "https_proxy",
"ALL_PROXY", "all_proxy",
}
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
// set can be parsed as a proxy URL.
//
// httpproxy discards a parse error and falls back to a direct connection, so an
// operator who mistypes a proxy URL would silently lose the egress path they
// meant to enforce. Modules exposing an environment proxy flag call this from
// their Validate so that startup fails loudly instead.
//
// Values are never included in the error: a proxy URL may carry credentials.
func ValidateEnvironmentProxyVariables() error {
var err error
for _, name := range environmentProxyVariables {
if os.Getenv(name) == "" {
continue
}
if !isUsableProxyURL(os.Getenv(name)) {
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
}
}
return err
}
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
// or anything that becomes one once a scheme is prefixed.
func isUsableProxyURL(value string) bool {
proxyURL, err := url.Parse(value)
if err == nil {
switch proxyURL.Scheme {
case "http", "https", "socks5", "socks5h":
return true
}
}
// httpproxy retries bare values such as "host:3128" with a scheme.
_, err = url.Parse("http://" + value)
return err == nil
}
// secureDialContext consumes the [OutboundDecision] stashed in ctx by // secureDialContext consumes the [OutboundDecision] stashed in ctx by
// [outboundRoundTripper]. When the decision is to bypass (allow-list // [outboundRoundTripper]. When the decision is to bypass (allow-list
// match), it dials directly. When the decision contains pinned IPs, it // match), it dials directly. When the decision contains pinned IPs, it
@@ -452,3 +567,110 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
} }
return nil, lastErr return nil, lastErr
} }
// DialThroughProxy opens a TCP tunnel to target (a host:port) through the
// HTTP CONNECT proxy at proxyURL, authenticating with any credentials
// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass
// a plain dialer. Chromium and soffice cannot authenticate to a proxy
// themselves, so Gotenberg performs the CONNECT handshake on their behalf.
// The returned connection carries the raw tunnel for the caller to splice
// with the client. See https://github.com/gotenberg/gotenberg/issues/1592.
func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) {
conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL))
if err != nil {
return nil, fmt.Errorf("dial proxy: %w", err)
}
if proxyURL.Scheme == "https" {
tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()})
err = tlsConn.HandshakeContext(ctx)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("TLS handshake with proxy: %w", err)
}
conn = tlsConn
}
// Bound the CONNECT handshake by the request deadline; cleared once the
// tunnel is established so splicing manages its own lifetime.
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
connectReq := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Opaque: target},
Host: target,
Header: make(http.Header),
}
if user := proxyURL.User; user != nil {
password, _ := user.Password()
connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password))
}
err = connectReq.Write(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("write CONNECT to proxy: %w", err)
}
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, connectReq)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("read CONNECT response from proxy: %w", err)
}
// A CONNECT response carries no body; discard defensively.
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = conn.Close()
return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode)
}
_ = conn.SetDeadline(time.Time{})
// The reader may hold bytes the proxy sent right after the response;
// overlay it so those tunnel bytes are not lost when splicing.
return &bufferedConn{Conn: conn, r: br}, nil
}
// proxyHostPort returns proxyURL's host:port, defaulting the port from the
// scheme when the URL omits it.
func proxyHostPort(proxyURL *url.URL) string {
port := proxyURL.Port()
if port == "" {
port = "80"
if proxyURL.Scheme == "https" {
port = "443"
}
}
return net.JoinHostPort(proxyURL.Hostname(), port)
}
// proxyAuthHeader builds a Basic Proxy-Authorization header value.
func proxyAuthHeader(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes
// buffered while reading a proxy's CONNECT response are not lost when the
// tunnel is spliced.
type bufferedConn struct {
net.Conn
r *bufio.Reader
}
func (c *bufferedConn) Read(b []byte) (int, error) {
return c.r.Read(b)
}
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
// the method, so a CONNECT splice over this connection could never signal EOF
// to the upstream and both sides waited for the other until a timeout.
func (c *bufferedConn) CloseWrite() error {
cw, ok := c.Conn.(interface{ CloseWrite() error })
if !ok {
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
}
return cw.CloseWrite()
}

View File

@@ -0,0 +1,155 @@
package gotenberg
import (
"net"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
)
func TestValidateEnvironmentProxyVariables(t *testing.T) {
for _, tc := range []struct {
name string
env map[string]string
wantErr bool
// wantIn is a substring the error must name, so that an operator can
// find the offending variable.
wantIn string
}{
{
name: "nothing set",
env: map[string]string{},
},
{
name: "well formed URL",
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
},
{
name: "credentials are accepted",
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
},
{
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
},
{
name: "socks5 is accepted",
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
},
{
name: "lowercase variables are checked too",
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
},
{
name: "unparseable URL",
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
wantErr: true,
wantIn: "HTTP_PROXY",
},
{
name: "the failing variable is named",
env: map[string]string{"HTTPS_PROXY": "://%zz"},
wantErr: true,
wantIn: "HTTPS_PROXY",
},
} {
t.Run(tc.name, func(t *testing.T) {
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
for name, value := range tc.env {
t.Setenv(name, value)
}
err := ValidateEnvironmentProxyVariables()
if tc.wantErr && err == nil {
t.Fatal("expected an error, got none")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
t.Errorf("error %q does not name %q", err, tc.wantIn)
}
})
}
}
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
// proxy URL, which may embed a password, never reaches the error text.
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
err := ValidateEnvironmentProxyVariables()
if err == nil {
t.Fatal("expected an error, got none")
}
if strings.Contains(err.Error(), "hunter2") {
t.Errorf("error leaks the proxy password: %q", err)
}
if strings.Contains(err.Error(), "admin") {
t.Errorf("error leaks the proxy username: %q", err)
}
}
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
// test for the dial-pinning gap: with the environment proxy enabled but no
// proxy applicable to the request, the dial must still go through the pinning
// dialer rather than a plain one.
//
// The request targets a hostname that only the stub resolver knows, so a plain
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
// which substitutes the address resolved during validation, can connect.
// See https://github.com/gotenberg/gotenberg/issues/1592.
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
if err != nil {
t.Fatalf("split server address: %v", err)
}
const host = "pinned-only.invalid"
// NO_PROXY covers the destination, so httpproxy declines it and the
// transport dials directly. That direct dial is the hop that used to lose
// pinning.
for _, name := range environmentProxyVariables {
t.Setenv(name, "")
}
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
t.Setenv("NO_PROXY", host)
withStubResolver(t, func(string) ([]netip.Addr, error) {
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
})
client := NewOutboundHttpClient(0, nil, nil, true)
rt, ok := client.Transport.(*outboundRoundTripper)
if !ok {
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
}
if rt.proxyFunc == nil {
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
}
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
if err != nil {
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
}

View File

@@ -0,0 +1,153 @@
package gotenberg
import (
"bufio"
"context"
"encoding/base64"
"io"
"net"
"net/http"
"net/url"
"sync"
"testing"
"time"
)
// connectCapture records the CONNECT request a proxy stub received.
type connectCapture struct {
mu sync.Mutex
method string
host string
auth string
}
func (c *connectCapture) set(method, host, auth string) {
c.mu.Lock()
defer c.mu.Unlock()
c.method, c.host, c.auth = method, host, auth
}
func (c *connectCapture) get() (string, string, string) {
c.mu.Lock()
defer c.mu.Unlock()
return c.method, c.host, c.auth
}
// startConnectProxyStub starts a raw TCP server that behaves like an HTTP
// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a
// greeting appended to the same write (to exercise buffered-byte handling),
// then echoes tunnel bytes back to the caller.
func startConnectProxyStub(t *testing.T, capture *connectCapture) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = l.Close() })
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
br := bufio.NewReader(conn)
req, err := http.ReadRequest(br)
if err != nil {
return
}
capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization"))
// The greeting rides along with the response so the client's CONNECT
// response parser buffers it; bufferedConn must not drop it.
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO"))
_, _ = io.Copy(conn, br)
}()
return l.Addr().String()
}
func TestDialThroughProxy(t *testing.T) {
capture := &connectCapture{}
addr := startConnectProxyStub(t, capture)
proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
})
if err != nil {
t.Fatalf("DialThroughProxy: %v", err)
}
defer func() { _ = conn.Close() }()
// The greeting buffered while reading the CONNECT response must survive.
greeting := make([]byte, len("TUNNEL-HELLO"))
_, err = io.ReadFull(conn, greeting)
if err != nil {
t.Fatalf("read greeting: %v", err)
}
if string(greeting) != "TUNNEL-HELLO" {
t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting)
}
// The tunnel must round-trip bytes.
_, err = conn.Write([]byte("ping"))
if err != nil {
t.Fatalf("write to tunnel: %v", err)
}
echo := make([]byte, 4)
_, err = io.ReadFull(conn, echo)
if err != nil {
t.Fatalf("read echo: %v", err)
}
if string(echo) != "ping" {
t.Fatalf("echo = %q, want ping", echo)
}
method, host, auth := capture.get()
if method != http.MethodConnect {
t.Fatalf("proxy saw method %q, want CONNECT", method)
}
if host != "example.com:443" {
t.Fatalf("proxy saw target %q, want example.com:443", host)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t"))
if auth != wantAuth {
t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth)
}
}
func TestDialThroughProxy_RefusedStatus(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = l.Close() })
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
br := bufio.NewReader(conn)
_, _ = http.ReadRequest(br)
_, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"))
}()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
})
if err == nil {
t.Fatal("expected an error when the proxy refuses CONNECT, got nil")
}
}

View File

@@ -57,12 +57,13 @@ type Api struct {
} }
type downloadFromConfig struct { type downloadFromConfig struct {
allowList []*regexp2.Regexp allowList []*regexp2.Regexp
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
maxRetry int enableEnvironmentProxy bool
disable bool maxRetry int
disable bool
} }
// Router is a module interface that adds routes to the [Api]. // Router is a module interface that adds routes to the [Api].
@@ -201,6 +202,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services") fs.Bool("api-download-from-deny-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.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.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") 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-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route") fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
@@ -239,12 +241,13 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header") a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{ a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexpSlice("api-download-from-allow-list"), allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"), denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"), denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"), denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"),
maxRetry: flags.MustInt("api-download-from-max-retry"), enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"),
disable: flags.MustBool("api-disable-download-from"), 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.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry") a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
@@ -377,6 +380,13 @@ func (a *Api) Validate() error {
err = errors.Join(err, errors.New("IP must be a valid IP address")) err = errors.Join(err, errors.New("IP must be a valid IP address"))
} }
if a.downloadFromCfg.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
}
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") { if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = errors.Join(err, err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"), errors.New("both TLS certificate and key files must be set"),

View File

@@ -281,7 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
} }
client := &retryablehttp.Client{ client := &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...), HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
RetryMax: downloadFromCfg.maxRetry, RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second, RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline), RetryWaitMax: time.Until(deadline),

View File

@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -150,9 +149,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
filename := c.Request().Header.Get("Gotenberg-Output-Filename") filename := c.Request().Header.Get("Gotenberg-Output-Filename")
// Keep only the last path segment, so that a caller cannot name an
// output file after a path.
// See https://github.com/gotenberg/gotenberg/issues/1227. // See https://github.com/gotenberg/gotenberg/issues/1227.
//
// [filepath.Base] alone is not enough: on Linux it does not treat a
// backslash as a separator, and this value reaches archive entry
// names. Use the same sanitizer as the other caller-supplied
// filenames.
if filename != "" { if filename != "" {
filename = filepath.Base(filename) filename = sanitizeFilename(filename)
} }
c.Set("outputFilename", filename) c.Set("outputFilename", filename)
// Call the next middleware in the chain. // Call the next middleware in the chain.

View File

@@ -10,6 +10,53 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
// TestOutputFilenameMiddleware pins the sanitizing of the
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
// a Content-Disposition header, so a path separator must never survive it.
// See https://github.com/gotenberg/gotenberg/issues/1227 and
// GHSA-hwc4-gmrw-5222.
func TestOutputFilenameMiddleware(t *testing.T) {
for _, tc := range []struct {
name string
header string
want string
}{
{"no header", "", ""},
{"plain filename", "foo", "foo"},
{"POSIX path", "/tmp/foo", "foo"},
{"POSIX traversal", "../../../etc/passwd", "passwd"},
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
{"mixed separators", `a/b\c`, "c"},
{"trailing separator", "/tmp/", ""},
{"bare dot dot", "..", ".."},
{"control characters", "fo\x01o\x7f", "foo"},
} {
t.Run(tc.name, func(t *testing.T) {
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
req := httptest.NewRequest(http.MethodPost, "/", nil)
if tc.header != "" {
req.Header.Set("Gotenberg-Output-Filename", tc.header)
}
c := echo.New().NewContext(req, httptest.NewRecorder())
err := handler(c)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
got, ok := c.Get("outputFilename").(string)
if !ok {
t.Fatal("outputFilename is not set as a string")
}
if got != tc.want {
t.Errorf("outputFilename = %q, want %q", got, tc.want)
}
})
}
}
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) { func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
mw := hardTimeoutMiddleware(100 * time.Millisecond) mw := hardTimeoutMiddleware(100 * time.Millisecond)
handler := mw(func(c echo.Context) error { return nil }) handler := mw(func(c echo.Context) error { return nil })

View File

@@ -38,6 +38,7 @@ type browserArguments struct {
allowFileAccessFromFiles bool allowFileAccessFromFiles bool
hostResolverRules string hostResolverRules string
proxyServer string proxyServer string
enableEnvironmentProxy bool
wsUrlReadTimeout time.Duration wsUrlReadTimeout time.Duration
hyphenDataDirPath string hyphenDataDirPath string
@@ -58,6 +59,14 @@ type chromiumBrowser struct {
userProfileDirPath string userProfileDirPath string
ctxMu sync.RWMutex ctxMu sync.RWMutex
isStarted atomic.Bool isStarted atomic.Bool
// startMu serializes Start calls. The supervisor's runWithDeadline
// abandons a Start goroutine when the request deadline expires while
// Chromium's startup handshake is still hanging; the abandoned goroutine
// keeps running, holding the resources it acquired (the pinning proxy).
// Serializing here prevents a second, overlapping Start from colliding
// with the in-flight one on the shared pinning proxy.
// See https://github.com/gotenberg/gotenberg/issues/1599.
startMu sync.Mutex
arguments browserArguments arguments browserArguments
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
@@ -69,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
initialCtx: context.Background(), initialCtx: context.Background(),
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs), pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy),
} }
b.isStarted.Store(false) b.isStarted.Store(false)
@@ -77,6 +86,19 @@ func newChromiumBrowser(arguments browserArguments) browser {
} }
func (b *chromiumBrowser) Start(logger *slog.Logger) error { func (b *chromiumBrowser) Start(logger *slog.Logger) error {
// Refuse to run while a previous Start is still in flight. That previous
// Start may be a goroutine the supervisor abandoned after the request
// deadline expired while the Chromium startup handshake was hanging; it
// still holds the pinning proxy it started. An abandoned goroutine keeps
// holding startMu until it unwinds (bounded by --chromium-start-timeout),
// so no overlapping Start can collide with it on the shared pinning proxy
// and latch Chromium into a permanent "pinning proxy already started"
// state. See https://github.com/gotenberg/gotenberg/issues/1599.
if !b.startMu.TryLock() {
return errors.New("browser start already in progress")
}
defer b.startMu.Unlock()
if b.isStarted.Load() { if b.isStarted.Load() {
return errors.New("browser is already started") return errors.New("browser is already started")
} }

View File

@@ -0,0 +1,39 @@
package chromium
import (
"context"
"log/slog"
"strings"
"testing"
)
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
// reported in https://github.com/gotenberg/gotenberg/issues/1599. When the
// supervisor abandons a Start goroutine on request-deadline expiry, that
// goroutine keeps running and holds startMu (and the pinning proxy it started)
// until it unwinds. A second Start must be refused rather than proceeding to
// start the pinning proxy a second time.
func TestChromiumBrowser_Start_rejectsOverlappingStart(t *testing.T) {
b := &chromiumBrowser{initialCtx: context.Background()}
// Simulate a Start still in flight.
b.startMu.Lock()
defer b.startMu.Unlock()
err := b.Start(slog.New(slog.DiscardHandler))
if err == nil {
t.Fatal("expected an error when a start is already in progress, got nil")
}
if !strings.Contains(err.Error(), "already in progress") {
t.Fatalf("expected an 'already in progress' error, got %q", err)
}
// The guard must return before touching any startup resource, so no user
// profile directory is created and the browser stays not started.
if b.userProfileDirPath != "" {
t.Fatalf("expected no user profile directory to be created, got %q", b.userProfileDirPath)
}
if b.isStarted.Load() {
t.Fatal("expected the browser to stay not started")
}
}

View File

@@ -300,7 +300,8 @@ type PdfOptions struct {
PreferCssPageSize bool PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be // GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF. // embedded into the PDF. Chromium derives the outline from the tagged-PDF
// structure tree, so enabling this implies GenerateTaggedPdf.
GenerateDocumentOutline bool GenerateDocumentOutline bool
// GenerateTaggedPdf defines whether to generate tagged (accessible) // GenerateTaggedPdf defines whether to generate tagged (accessible)
@@ -459,6 +460,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs") fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver") fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests") fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values") 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.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-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")
@@ -506,6 +508,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"), allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
hostResolverRules: flags.MustString("chromium-host-resolver-rules"), hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
proxyServer: flags.MustString("chromium-proxy-server"), proxyServer: flags.MustString("chromium-proxy-server"),
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"), wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath, hyphenDataDirPath: hyphenDataDirPath,
@@ -666,6 +669,13 @@ func (mod *Chromium) Validate() error {
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency) return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
} }
if mod.args.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
}
}
_, err := os.Stat(mod.args.binPath) _, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err) return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)

View File

@@ -10,6 +10,7 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"time"
"github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch" "github.com/chromedp/cdproto/fetch"
@@ -62,6 +63,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
} }
// Shared by every scope match of this conversion, across all paused
// requests. Its lifetime is the conversion, as this function is called once
// per conversion with that conversion's context.
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
chromedp.ListenTarget(ctx, func(ev any) { chromedp.ListenTarget(ctx, func(ev any) {
if e, ok := ev.(*fetch.EventRequestPaused); ok { if e, ok := ev.(*fetch.EventRequestPaused); ok {
go func() { go func() {
@@ -127,6 +133,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
// First, we have to check if at least one header has to be // First, we have to check if at least one header has to be
// set for the current request. // set for the current request.
for _, header := range options.extraHttpHeaders { for _, header := range options.extraHttpHeaders {
// This goroutine outlives the response: nothing cancels an
// in-flight match, so stop as soon as the conversion is over.
select {
case <-ctx.Done():
return
default:
}
if header.Scope == nil { if header.Scope == nil {
// Non-scoped header. // Non-scoped header.
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
@@ -134,7 +148,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
continue continue
} }
if !budget.tryAcquire() {
// Treat the remaining scoped headers as non-matching rather
// than spending more CPU on a request the client may already
// have given up on.
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
break
}
matchStart := time.Now()
ok, err := header.Scope.MatchString(e.Request.URL) ok, err := header.Scope.MatchString(e.Request.URL)
budget.consume(time.Since(matchStart))
switch { switch {
case err != nil: case err != nil:
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))

View File

@@ -9,10 +9,12 @@ import (
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"sync" "sync"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -43,6 +45,14 @@ type pinningProxy struct {
// allow-list opt-in). Tests may override it. // allow-list opt-in). Tests may override it.
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error) dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
// upstreamProxy resolves the upstream (corporate) proxy for a
// destination URL from the standard proxy environment variables, or
// returns a nil URL to connect directly. It is nil unless the operator
// opted into proxy-environment honoring. When set, the pinning proxy
// performs the authenticated proxy handshake that Chromium cannot. See
// https://github.com/gotenberg/gotenberg/issues/1592.
upstreamProxy func(*url.URL) (*url.URL, error)
listener net.Listener listener net.Listener
server *http.Server server *http.Server
wg sync.WaitGroup wg sync.WaitGroup
@@ -57,8 +67,8 @@ type pinningProxy struct {
// [gotenberg.DecideOutbound] on every request the proxy sees, so // [gotenberg.DecideOutbound] on every request the proxy sees, so
// Chromium inherits whatever posture the operator selected. The // Chromium inherits whatever posture the operator selected. The
// returned proxy is not yet listening; call Start. // returned proxy is not yet listening; call Start.
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy { func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
return &pinningProxy{ p := &pinningProxy{
allowList: allowList, allowList: allowList,
denyList: denyList, denyList: denyList,
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) { decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
@@ -73,6 +83,14 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny
return dialer.DialContext(ctx, network, addr) return dialer.DialContext(ctx, network, addr)
}, },
} }
if enableEnvironmentProxy {
// Honor the standard proxy environment variables, credentials
// included. httpproxy reads the environment now and applies NO_PROXY.
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
}
return p
} }
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a // Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
@@ -188,8 +206,24 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
return return
} }
// When the operator routes egress through an authenticated proxy,
// Chromium cannot supply the credentials itself, so the pinning proxy
// performs the CONNECT (and authentication) upstream. The decision above
// still gated the destination through the allow/deny and IP-class rules.
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host})
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err))
http.Error(w, "upstream proxy error", http.StatusBadGateway)
return
}
}
var upstream net.Conn var upstream net.Conn
switch { switch {
case proxyURL != nil:
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
case decision.Bypass: case decision.Bypass:
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host) upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
case len(decision.Pinned) > 0: case len(decision.Pinned) > 0:
@@ -275,17 +309,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
return return
} }
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(req.URL)
if err != nil {
p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err))
http.Error(w, "upstream proxy error", http.StatusBadGateway)
return
}
}
outReq := req.Clone(req.Context()) outReq := req.Clone(req.Context())
outReq.RequestURI = "" outReq.RequestURI = ""
stripHopByHopHeaders(outReq.Header) stripHopByHopHeaders(outReq.Header)
// 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.
transport := &http.Transport{ 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, DisableKeepAlives: true,
Proxy: nil, }
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { if proxyURL != nil {
// The upstream proxy owns DNS and egress; Go adds Proxy-Authorization
// from the URL's credentials. The decision above already gated the
// destination, and dialBypass dials the proxy host directly.
transport.Proxy = http.ProxyURL(proxyURL)
transport.DialContext = p.dialBypass
} else {
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
_, port, splitErr := net.SplitHostPort(addr) _, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil { if splitErr != nil {
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr) return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
@@ -298,7 +350,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
default: default:
return nil, errors.New("no pinned addresses and not bypassed") return nil, errors.New("no pinned addresses and not bypassed")
} }
}, }
} }
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()
@@ -364,3 +416,11 @@ func isClientCancellation(ctx context.Context, err error) bool {
} }
return ctx.Err() != nil return ctx.Err() != nil
} }
// dialThroughUpstreamProxy tunnels to target through the upstream proxy,
// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that
// Chromium cannot. dialBypass dials the proxy itself and is overridable in
// tests. See https://github.com/gotenberg/gotenberg/issues/1592.
func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) {
return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass)
}

View File

@@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
upstreamURL := mustParseURL(t, upstream.URL) upstreamURL := mustParseURL(t, upstream.URL)
var decideCalls atomic.Int32 var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1) decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
@@ -151,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
} }
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) { func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
} }
@@ -187,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) {
upstreamURL := mustParseURL(t, upstream.URL) upstreamURL := mustParseURL(t, upstream.URL)
var bypassCalls atomic.Int32 var bypassCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Bypass: true}, nil return gotenberg.OutboundDecision{Bypass: true}, nil
} }
@@ -236,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
t.Cleanup(upstream.Close) t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL) upstreamURL := mustParseURL(t, upstream.URL)
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { 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 return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
} }
@@ -276,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
} }
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) { func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
t.Fatal("decide must not be called for malformed proxy request") t.Fatal("decide must not be called for malformed proxy request")
return gotenberg.OutboundDecision{}, nil return gotenberg.OutboundDecision{}, nil
@@ -316,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
t.Cleanup(stop) t.Cleanup(stop)
var decideCalls atomic.Int32 var decideCalls atomic.Int32
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1) decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
@@ -386,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
} }
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) { func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
} }
@@ -445,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered) return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
} }
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = stubDecide p.decide = stubDecide
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) { 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" { if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" {
@@ -488,7 +488,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
// [TestPinningProxy_CONNECT_BlockedByDecide]. // [TestPinningProxy_CONNECT_BlockedByDecide].
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
// Mimic the wrap chain produced by outbound.resolveHost when the // Mimic the wrap chain produced by outbound.resolveHost when the
// DNS lookup is canceled mid-flight by Chromium hanging up. // DNS lookup is canceled mid-flight by Chromium hanging up.
@@ -547,7 +547,7 @@ func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
// HTTP forward requests aborted by the client must also log at debug. // HTTP forward requests aborted by the client must also log at debug.
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded) return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded)
} }
@@ -596,7 +596,7 @@ func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
// still surface at warn level so operators see real refusals. // still surface at warn level so operators see real refusals.
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) { func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered) return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
} }
@@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn]. // [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { 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 return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
} }
@@ -713,7 +713,7 @@ func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
// must still warn so operators see real problems. // must still warn so operators see real problems.
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) { func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { 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 return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
} }
@@ -770,7 +770,7 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
// logs at debug, not warn. Genuine RoundTrip failures still warn. // logs at debug, not warn. Genuine RoundTrip failures still warn.
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} rec := &recordingHandler{}
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { 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 return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil
} }
@@ -868,7 +868,7 @@ func TestIsClientCancellation(t *testing.T) {
} }
func TestPinningProxy_StartTwice(t *testing.T) { func TestPinningProxy_StartTwice(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
err := p.Start(testLogger()) err := p.Start(testLogger())
if err != nil { if err != nil {
t.Fatalf("first Start: %v", err) t.Fatalf("first Start: %v", err)
@@ -882,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
} }
func TestPinningProxy_StopIdempotent(t *testing.T) { func TestPinningProxy_StopIdempotent(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
// Stop on a never-started proxy is a no-op. // Stop on a never-started proxy is a no-op.
if err := p.Stop(testLogger()); err != nil { if err := p.Stop(testLogger()); err != nil {
t.Fatalf("Stop on never-started proxy: %v", err) t.Fatalf("Stop on never-started proxy: %v", err)

View File

@@ -0,0 +1,76 @@
package chromium
import (
"context"
"encoding/base64"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"sync/atomic"
"testing"
"time"
"github.com/dlclark/regexp2"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the
// operator opts into proxy-environment honoring, a plain HTTP request is
// forwarded through the upstream (corporate) proxy with the credentials
// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592.
func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) {
var gotAuth atomic.Value
gotAuth.Store("")
// Stand-in for the corporate proxy: an HTTP server that receives the
// forwarded request and records the injected Proxy-Authorization.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Proxy-Authorization"))
_, _ = fmt.Fprint(w, "via-corporate-proxy")
}))
t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL)
upstreamURL.User = url.UserPassword("bob", "pw")
p := newPinningProxy(nil, nil, false, false, true)
// Force every destination through our stub upstream proxy.
p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil }
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) {
t.Fatal("dialPinned must not be called when routing through an upstream proxy")
return nil, nil
}
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 func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if string(body) != "via-corporate-proxy" {
t.Fatalf("body = %q, want via-corporate-proxy", body)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw"))
if got := gotAuth.Load().(string); got != wantAuth {
t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth)
}
}

View File

@@ -24,6 +24,20 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
) )
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
// scoped header against every paused sub-resource request, so the total
// matching work is the product of the header count and the sub-resource count.
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
const (
maxExtraHttpHeaders = 64
maxExtraHttpHeaderScopeLength = 1024
// A scope pattern matches against a URL, which takes microseconds for any
// reasonable pattern.
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
)
var sameSiteRegexp = regexp2.MustCompile( var sameSiteRegexp = regexp2.MustCompile(
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`, `("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
regexp2.None, regexp2.None,
@@ -169,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err) return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
} }
if len(headers) > maxExtraHttpHeaders {
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
}
for k, v := range headers { for k, v := range headers {
var scope string var scope string
var valueTokens []string var valueTokens []string
@@ -198,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var scopeRegexp *regexp2.Regexp var scopeRegexp *regexp2.Regexp
if len(scope) > 0 { if len(scope) > 0 {
if len(scope) > maxExtraHttpHeaderScopeLength {
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
continue
}
p, errCompile := regexp2.Compile(scope, regexp2.None) p, errCompile := regexp2.Compile(scope, regexp2.None)
if errCompile != nil { if errCompile != nil {
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile)) err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue continue
} }
p.MatchTimeout = 5 * time.Second p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
scopeRegexp = p scopeRegexp = p
} }

View File

@@ -0,0 +1,52 @@
package chromium
import (
"sync/atomic"
"time"
)
// scopeMatchBudgetPerConversion caps the total time a single conversion may
// spend matching scoped extra HTTP header patterns.
//
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
// pauses every sub-resource request, and each paused request is matched against
// every scoped header. Without a shared budget the total is the product of the
// two, both of which the client controls.
// See https://github.com/gotenberg/gotenberg/issues/1588.
const scopeMatchBudgetPerConversion = 5 * time.Second
// scopeMatchBudget is a time allowance shared by every scope match of a
// conversion. It is safe for concurrent use: paused requests are handled on
// their own goroutines.
type scopeMatchBudget struct {
remaining atomic.Int64
}
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
b := new(scopeMatchBudget)
b.remaining.Store(int64(d))
return b
}
// tryAcquire reports whether the budget still allows a match.
func (b *scopeMatchBudget) tryAcquire() bool {
return b.remaining.Load() > 0
}
// consume subtracts the time a match took. It saturates at zero so that a long
// match cannot wrap the counter back into credit.
func (b *scopeMatchBudget) consume(d time.Duration) {
for {
current := b.remaining.Load()
if current <= 0 {
return
}
next := max(current-int64(d), 0)
if b.remaining.CompareAndSwap(current, next) {
return
}
}
}

View File

@@ -0,0 +1,122 @@
package chromium
import (
"strings"
"sync"
"testing"
"time"
"github.com/dlclark/regexp2"
)
func TestScopeMatchBudget(t *testing.T) {
t.Run("allows matching while credit remains", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
if !b.tryAcquire() {
t.Fatal("tryAcquire() = false on a fresh budget, want true")
}
})
t.Run("denies matching once exhausted", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Second)
if b.tryAcquire() {
t.Error("tryAcquire() = true after the budget was spent, want false")
}
})
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Hour)
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
if b.tryAcquire() {
t.Error("tryAcquire() = true after an overlong match, want false")
}
})
t.Run("a spent budget stays spent", func(t *testing.T) {
b := newScopeMatchBudget(time.Second)
b.consume(time.Second)
b.consume(time.Millisecond)
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
})
t.Run("is safe for concurrent use", func(t *testing.T) {
const goroutines = 64
// Each goroutine spends 1ms against a budget of half that many
// milliseconds, so the total spend overshoots it.
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
b.tryAcquire()
b.consume(time.Millisecond)
})
}
wg.Wait()
if got := b.remaining.Load(); got != 0 {
t.Errorf("remaining = %d, want 0", got)
}
})
}
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
// the amplification: many scoped headers matched against a hostile URL must cost
// the budget, not a multiple of it.
// See https://github.com/gotenberg/gotenberg/issues/1588.
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
const (
headers = 16
budget = 200 * time.Millisecond
)
// Nested quantifier with no possible match: classic catastrophic
// backtracking.
pattern := compileScopePattern(t, `(a+)+b`)
url := "http://example.com/" + strings.Repeat("a", 40)
b := newScopeMatchBudget(budget)
start := time.Now()
var matched int
for range headers {
if !b.tryAcquire() {
break
}
matchStart := time.Now()
_, _ = pattern.MatchString(url)
b.consume(time.Since(matchStart))
matched++
}
elapsed := time.Since(start)
if matched == headers {
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
}
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
// the worst case is the budget plus one final match that started with the
// last of the credit. Generous slack keeps this stable on a loaded CI box.
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
if elapsed > ceiling {
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
}
}
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
t.Helper()
p, err := regexp2.Compile(pattern, regexp2.None)
if err != nil {
t.Fatalf("compile %q: %v", pattern, err)
}
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
return p
}

View File

@@ -21,8 +21,29 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// resolvePdfOptions applies the cross-option constraints Chromium imposes
// before printing.
//
// Chromium derives the PDF document outline from the tagged-PDF structure
// tree, so [PdfOptions.GenerateDocumentOutline] produces no outline unless
// tagged PDF is also generated. Requesting an outline therefore implies
// tagged PDF. See https://github.com/gotenberg/gotenberg/issues/1579.
func resolvePdfOptions(options PdfOptions) PdfOptions {
if options.GenerateDocumentOutline {
options.GenerateTaggedPdf = true
}
return options
}
func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc { func printToPdfActionFunc(reqCtx context.Context, logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if options.GenerateDocumentOutline && !options.GenerateTaggedPdf {
logger.DebugContext(ctx, "document outline requested, enabling tagged PDF because Chromium derives the outline from the structure tree")
}
options = resolvePdfOptions(options)
// ctx is the chromedp task context, derived from context.Background(), // ctx is the chromedp task context, derived from context.Background(),
// so the span is started under reqCtx to keep print_to_pdf in the // so the span is started under reqCtx to keep print_to_pdf in the
// conversion trace instead of orphaning it into a new one. // conversion trace instead of orphaning it into a new one.

View File

@@ -0,0 +1,52 @@
package chromium
import "testing"
func TestResolvePdfOptions(t *testing.T) {
for _, tc := range []struct {
scenario string
generateOutline bool
generateTaggedIn bool
generateTaggedWant bool
}{
{
scenario: "outline requested forces tagged PDF",
generateOutline: true,
generateTaggedIn: false,
generateTaggedWant: true,
},
{
scenario: "outline requested keeps tagged PDF on",
generateOutline: true,
generateTaggedIn: true,
generateTaggedWant: true,
},
{
scenario: "no outline leaves tagged PDF off",
generateOutline: false,
generateTaggedIn: false,
generateTaggedWant: false,
},
{
scenario: "no outline keeps tagged PDF on",
generateOutline: false,
generateTaggedIn: true,
generateTaggedWant: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
options := DefaultPdfOptions()
options.GenerateDocumentOutline = tc.generateOutline
options.GenerateTaggedPdf = tc.generateTaggedIn
got := resolvePdfOptions(options)
if got.GenerateTaggedPdf != tc.generateTaggedWant {
t.Errorf("expected GenerateTaggedPdf=%t, got %t", tc.generateTaggedWant, got.GenerateTaggedPdf)
}
if got.GenerateDocumentOutline != tc.generateOutline {
t.Errorf("expected GenerateDocumentOutline=%t, got %t", tc.generateOutline, got.GenerateDocumentOutline)
}
})
}
}

View File

@@ -33,12 +33,33 @@ var (
// formats option. // formats option.
ErrInvalidPdfFormats = errors.New("invalid PDF formats") ErrInvalidPdfFormats = errors.New("invalid PDF formats")
// ErrUnoException happens when unoconverter returns exit code 5. // ErrUnoException happens when unoconverter returns exit code 5. That code
// is the residual bucket of unoconverter's catch-all UNO exception handler:
// it covers a malformed page range, a password supplied to a document that
// does not need one, a failure to open the document and a failure to write
// the output alike. It names the exception class that was caught, not a
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
ErrUnoException = errors.New("uno exception") ErrUnoException = errors.New("uno exception")
// ErrRuntimeException happens when unoconverter returns exit code 6. // ErrRuntimeException happens when unoconverter returns exit code 6.
// unoconverter's own message for it reads "Office probably died", yet a
// wrong or missing password also surfaces there. Like [ErrUnoException], it
// does not establish who is at fault.
ErrRuntimeException = errors.New("runtime exception") ErrRuntimeException = errors.New("runtime exception")
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
// could not read the source document.
ErrIoException = errors.New("io exception")
// ErrCannotConvertException happens when unoconverter returns exit code 4.
// LibreOffice read the document but could not convert it to PDF.
ErrCannotConvertException = errors.New("cannot convert exception")
// ErrIllegalArgumentException happens when unoconverter returns exit code
// 8. LibreOffice rejected the source document, usually because its contents
// do not match its extension.
ErrIllegalArgumentException = errors.New("illegal argument exception")
// ErrCoreDumped happens randomly; sometimes a conversion will work as // ErrCoreDumped happens randomly; sometimes a conversion will work as
// expected, and some other time the same conversion will fail. // expected, and some other time the same conversion will fail.
// See https://github.com/gotenberg/gotenberg/issues/639. // See https://github.com/gotenberg/gotenberg/issues/639.
@@ -336,6 +357,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches 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-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") 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")
fs.Bool("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
return fs return fs
}(), }(),
@@ -363,10 +385,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
unoBinPath: unoBinPath, unoBinPath: unoBinPath,
startTimeout: flags.MustDuration("libreoffice-start-timeout"), startTimeout: flags.MustDuration("libreoffice-start-timeout"),
proxyOptions: outboundProxyOptions{ proxyOptions: outboundProxyOptions{
allowList: flags.MustRegexpSlice("libreoffice-allow-list"), allowList: flags.MustRegexpSlice("libreoffice-allow-list"),
denyList: flags.MustRegexpSlice("libreoffice-deny-list"), denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"), denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"), denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
}, },
} }
@@ -499,6 +522,13 @@ func (a *Api) Validate() error {
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr)) err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
} }
if a.args.proxyOptions.enableEnvironmentProxy {
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
if proxyErr != nil {
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
}
}
return err return err
} }
@@ -765,7 +795,10 @@ func conversionRequestAttributes(inputPath string, options Options) []attribute.
// [gotenberg.ClassifyError]. // [gotenberg.ClassifyError].
func libreofficeErrorType(err error) string { func libreofficeErrorType(err error) string {
switch { switch {
case errors.Is(err, ErrInvalidPdfFormats): case errors.Is(err, ErrInvalidPdfFormats),
errors.Is(err, ErrIoException),
errors.Is(err, ErrCannotConvertException),
errors.Is(err, ErrIllegalArgumentException):
return gotenberg.ErrorTypeInvalidInput return gotenberg.ErrorTypeInvalidInput
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException): case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
return "libreoffice_exception" return "libreoffice_exception"

View File

@@ -17,6 +17,9 @@ func TestLibreofficeErrorType(t *testing.T) {
{"deadline", context.DeadlineExceeded, "timeout"}, {"deadline", context.DeadlineExceeded, "timeout"},
{"canceled", context.Canceled, "context_cancelled"}, {"canceled", context.Canceled, "context_cancelled"},
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"}, {"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
{"io exception", ErrIoException, "invalid_input"},
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
{"illegal argument exception", ErrIllegalArgumentException, "invalid_input"},
{"uno exception", ErrUnoException, "libreoffice_exception"}, {"uno exception", ErrUnoException, "libreoffice_exception"},
{"runtime exception", ErrRuntimeException, "libreoffice_exception"}, {"runtime exception", ErrRuntimeException, "libreoffice_exception"},
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"}, {"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},

View File

@@ -435,9 +435,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
return nil return nil
} }
// LibreOffice's errors are not explicit. // LibreOffice's errors are not explicit: unoconverter derives its exit code
// For instance, exit code 5 may be explained by a malformed page range // from the UNO exception class it caught, not from a diagnosis. Exit codes
// but also by a not required password. // 5 and 6 are ambiguous in particular, so the route decides the HTTP status
// from the request and the document rather than from the code alone.
// See https://github.com/gotenberg/gotenberg/issues/1588.
// We may want to retry in case of a core-dumped event. // We may want to retry in case of a core-dumped event.
// See https://github.com/gotenberg/gotenberg/issues/639. // See https://github.com/gotenberg/gotenberg/issues/639.
@@ -445,13 +447,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
return ErrCoreDumped return ErrCoreDumped
} }
if exitCode == 5 { switch exitCode {
// Potentially malformed page ranges or password not required. case 3:
return ErrIoException
case 4:
return ErrCannotConvertException
case 5:
return ErrUnoException return ErrUnoException
} case 6:
if exitCode == 6 {
// Password potentially required or invalid.
return ErrRuntimeException return ErrRuntimeException
case 8:
return ErrIllegalArgumentException
} }
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)

View File

@@ -0,0 +1,129 @@
package api
import (
"archive/zip"
"bytes"
"io"
"os"
"path/filepath"
"strings"
)
// PasswordProtection describes whether a document requires a password to open.
type PasswordProtection int
const (
// PasswordProtectionUnknown means the document's encryption state could not
// be determined.
PasswordProtectionUnknown PasswordProtection = iota
// PasswordProtectionNone means the document opens without a password.
PasswordProtectionNone
// PasswordProtectionRequired means the document is encrypted.
PasswordProtectionRequired
)
var (
// Compound File Binary magic. An encrypted OOXML document is an
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
// header signature is fixed.
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
// starts with it, so an intact package starts with it too.
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
// An unencrypted OOXML document is always a ZIP package, so any of these
// extensions over a compound file means the payload is encrypted. Legacy
// binary formats (.doc, .xls, .ppt) are compound files either way and are
// deliberately absent.
ooxmlExtensions = map[string]struct{}{
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {},
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
".ppsx": {}, ".ppsm": {},
}
)
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
// is a few kilobytes in practice; the cap stops a crafted archive from
// exhausting memory through its decompressed size.
const odfManifestSizeLimit = 1 << 20
// DetectPasswordProtection reports whether the document at path is encrypted.
//
// Detection is advisory and never fails: an unreadable file, an unknown format
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
// refine the diagnosis of a conversion that already failed, since LibreOffice's
// exit codes do not distinguish a missing password from a crash.
func DetectPasswordProtection(path string) PasswordProtection {
f, err := os.Open(path)
if err != nil {
return PasswordProtectionUnknown
}
defer func() {
_ = f.Close()
}()
magic := make([]byte, 8)
n, err := io.ReadFull(f, magic)
if err != nil && n < len(zipMagic) {
return PasswordProtectionUnknown
}
magic = magic[:n]
switch {
case bytes.HasPrefix(magic, ole2Magic):
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
return PasswordProtectionRequired
}
// A legacy binary document is a compound file whether or not it is
// encrypted; its encryption lives in a stream this cannot cheaply read.
return PasswordProtectionUnknown
case bytes.HasPrefix(magic, zipMagic):
return detectZipPasswordProtection(f)
default:
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
return PasswordProtectionUnknown
}
}
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
// package has no manifest, and reaching this point already proves it is not an
// MS-OFFCRYPTO container, so it opens without a password.
func detectZipPasswordProtection(f *os.File) PasswordProtection {
size, err := f.Seek(0, io.SeekEnd)
if err != nil {
return PasswordProtectionUnknown
}
r, err := zip.NewReader(f, size)
if err != nil {
return PasswordProtectionUnknown
}
manifest, err := r.Open("META-INF/manifest.xml")
if err != nil {
// No manifest: an OOXML package, or a ZIP that is not an office
// document at all. Neither is encrypted.
return PasswordProtectionNone
}
defer func() {
_ = manifest.Close()
}()
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
if err != nil {
return PasswordProtectionUnknown
}
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
// <manifest:encryption-data> child.
if bytes.Contains(content, []byte("encryption-data")) {
return PasswordProtectionRequired
}
return PasswordProtectionNone
}

View File

@@ -0,0 +1,193 @@
package api
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// writeFile writes content to a new file named name inside dir and returns its
// path.
func writeFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, content, 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
// writeZip builds a ZIP archive from entries and returns its path.
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
t.Helper()
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
for entryName, content := range entries {
f, err := w.Create(entryName)
if err != nil {
t.Fatalf("create zip entry %s: %v", entryName, err)
}
_, err = f.Write([]byte(content))
if err != nil {
t.Fatalf("write zip entry %s: %v", entryName, err)
}
}
err := w.Close()
if err != nil {
t.Fatalf("close zip writer: %v", err)
}
return writeFile(t, dir, name, buf.Bytes())
}
func TestDetectPasswordProtection(t *testing.T) {
dir := t.TempDir()
ole2 := func(name string) string {
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
}
for _, tc := range []struct {
name string
path string
want PasswordProtection
}{
{
name: "encrypted OOXML is a compound file",
path: ole2("encrypted.docx"),
want: PasswordProtectionRequired,
},
{
name: "extension casing is ignored",
path: ole2("encrypted.DOCX"),
want: PasswordProtectionRequired,
},
{
name: "encrypted spreadsheet",
path: ole2("encrypted.xlsx"),
want: PasswordProtectionRequired,
},
{
name: "legacy binary document is inconclusive",
path: ole2("legacy.doc"),
want: PasswordProtectionUnknown,
},
{
name: "plain OOXML package",
path: writeZip(t, dir, "plain.docx", map[string]string{
"[Content_Types].xml": "<Types/>",
"word/document.xml": "<w:document/>",
}),
want: PasswordProtectionNone,
},
{
name: "encrypted ODF declares encryption-data in its manifest",
path: writeZip(t, dir, "encrypted.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
"content.xml": "<office:document-content/>",
}),
want: PasswordProtectionRequired,
},
{
name: "plain ODF has a manifest without encryption-data",
path: writeZip(t, dir, "plain.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
"content.xml": "<office:document-content/>",
}),
want: PasswordProtectionNone,
},
{
name: "flat XML carries no encryption",
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
want: PasswordProtectionUnknown,
},
{
name: "plain text",
path: writeFile(t, dir, "notes.txt", []byte("hello")),
want: PasswordProtectionUnknown,
},
{
name: "file shorter than any magic",
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
want: PasswordProtectionUnknown,
},
{
name: "empty file",
path: writeFile(t, dir, "empty.docx", nil),
want: PasswordProtectionUnknown,
},
{
name: "truncated archive",
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
want: PasswordProtectionUnknown,
},
{
name: "non-existent path",
path: filepath.Join(dir, "does-not-exist.docx"),
want: PasswordProtectionUnknown,
},
{
name: "directory",
path: dir,
want: PasswordProtectionUnknown,
},
} {
t.Run(tc.name, func(t *testing.T) {
if got := DetectPasswordProtection(tc.path); got != tc.want {
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
}
})
}
}
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
// the integration scenarios upload, so a fixture swap cannot silently flip a
// status code.
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
for _, tc := range []struct {
path string
want PasswordProtection
}{
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
} {
t.Run(filepath.Base(tc.path), func(t *testing.T) {
if _, err := os.Stat(tc.path); err != nil {
t.Skipf("fixture unavailable: %v", err)
}
if got := DetectPasswordProtection(tc.path); got != tc.want {
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
}
})
}
}
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
// larger than the cap still yields a verdict through a bounded read.
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
dir := t.TempDir()
// Well past odfManifestSizeLimit, and highly compressible, so the archive
// on disk stays small.
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
path := writeZip(t, dir, "oversized.odt", map[string]string{
"mimetype": "application/vnd.oasis.opendocument.text",
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
})
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
}
}

View File

@@ -15,16 +15,18 @@ import (
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// outboundProxyOptions configures a [libreOfficeProxy]. // outboundProxyOptions configures a [libreOfficeProxy].
type outboundProxyOptions struct { type outboundProxyOptions struct {
allowList []*regexp2.Regexp allowList []*regexp2.Regexp
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
enableEnvironmentProxy bool
} }
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes // libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
opts outboundProxyOptions opts outboundProxyOptions
logger *slog.Logger logger *slog.Logger
// upstreamProxy resolves the upstream (corporate) proxy for a destination
// URL from the standard proxy environment variables, or returns a nil URL
// to connect directly. Nil unless the operator opted into proxy-
// environment honoring. See https://github.com/gotenberg/gotenberg/issues/1592.
upstreamProxy func(*url.URL) (*url.URL, error)
stopOnce sync.Once stopOnce sync.Once
} }
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
p := &libreOfficeProxy{ p := &libreOfficeProxy{
listener: listener, listener: listener,
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...), client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
opts: opts, opts: opts,
logger: logger.With(slog.String("logger", "libreoffice-proxy")), logger: logger.With(slog.String("logger", "libreoffice-proxy")),
} }
if opts.enableEnvironmentProxy {
// Honor the standard proxy environment variables, credentials
// included. httpproxy reads the environment now and applies NO_PROXY.
p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc()
}
p.server = &http.Server{ p.server = &http.Server{
Handler: p, Handler: p,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
return return
} }
// When the operator routes egress through an authenticated proxy, soffice
// cannot supply the credentials, so the proxy performs the CONNECT (and
// authentication) upstream. The decision above still gated the destination.
var proxyURL *url.URL
if p.upstreamProxy != nil {
proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)})
if err != nil {
p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy resolve upstream proxy for '%s': %s", rawURL, err))
http.Error(w, "proxy: upstream proxy error", http.StatusBadGateway)
return
}
}
var dest net.Conn var dest net.Conn
switch { switch {
case proxyURL != nil:
dest, err = gotenberg.DialThroughProxy(r.Context(), proxyURL, r.Host, func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.DialTimeout(network, addr, 10*time.Second)
})
case len(decision.Pinned) > 0: case len(decision.Pinned) > 0:
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port) dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
default: default:

View File

@@ -15,6 +15,11 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
) )
// unattributableFailureMessage is returned when LibreOffice fails and no
// client-supplied input is implicated. Its only format verb is the original
// filename.
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
// convertRoute returns an [api.Route] which can convert LibreOffice documents // convertRoute returns an [api.Route] which can convert LibreOffice documents
// to PDF. // to PDF.
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route { func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
@@ -405,20 +410,52 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
) )
} }
if errors.Is(err, libreofficeapi.ErrUnoException) { filename := ctx.OriginalFilename(inputPath)
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)), api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
) )
} }
if errors.Is(err, libreofficeapi.ErrRuntimeException) { if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
return api.WrapError( return api.WrapError(
fmt.Errorf("convert to PDF: %w", err), fmt.Errorf("convert to PDF: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."), api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
) )
} }
// Exit codes 5 and 6 name the UNO exception class that was
// caught, not a cause: both cover a client mistake and a
// LibreOffice crash. Blame the client only when one of its
// inputs is actually implicated, since the server is the
// only remaining explanation otherwise. Password evidence
// outranks page ranges: a password failure aborts on import,
// before the export filter applies any page range.
// See https://github.com/gotenberg/gotenberg/issues/1588.
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
protection := libreofficeapi.DetectPasswordProtection(inputPath)
var sentinel api.SentinelHttpError
switch {
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
case protection == libreofficeapi.PasswordProtectionRequired:
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
case options.Password != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
default:
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
}
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
}
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
} }

View File

@@ -0,0 +1,241 @@
package libreoffice
import (
"archive/zip"
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
)
// compoundFile writes a document whose header marks it as a compound file. Over
// an OOXML extension, that means an encrypted payload.
func compoundFile(t *testing.T, dir, name string) string {
t.Helper()
content := append(
[]byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1},
bytes.Repeat([]byte{0x00}, 64)...,
)
return writeTestFile(t, dir, name, content)
}
// zipPackage writes a minimal, unencrypted OOXML package.
func zipPackage(t *testing.T, dir, name string) string {
t.Helper()
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
f, err := w.Create("[Content_Types].xml")
if err != nil {
t.Fatalf("create zip entry: %v", err)
}
_, err = f.Write([]byte("<Types/>"))
if err != nil {
t.Fatalf("write zip entry: %v", err)
}
err = w.Close()
if err != nil {
t.Fatalf("close zip writer: %v", err)
}
return writeTestFile(t, dir, name, buf.Bytes())
}
func writeTestFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
err := os.WriteFile(path, content, 0o600)
if err != nil {
t.Fatalf("write %s: %v", path, err)
}
return path
}
// TestConvertRoute_FailureStatus pins the branch table that decides whether a
// LibreOffice failure is the client's fault. See
// https://github.com/gotenberg/gotenberg/issues/1588.
func TestConvertRoute_FailureStatus(t *testing.T) {
dir := t.TempDir()
var (
protected = compoundFile(t, dir, "protected_page_1.docx")
plain = zipPackage(t, dir, "page_1.docx")
legacy = compoundFile(t, dir, "legacy.doc")
corrupted = writeTestFile(t, dir, "corrupted.docx", []byte("not a document"))
unreachable = filepath.Join(dir, "vanished.docx")
)
for _, tc := range []struct {
name string
inputPath string
values map[string][]string
err error
wantStatus int
wantBody string
}{
{
name: "encrypted document, no password",
inputPath: protected,
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
},
{
name: "encrypted document, wrong password",
inputPath: protected,
values: map[string][]string{"password": {"bar"}},
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusBadRequest,
wantBody: "The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.",
},
{
name: "unencrypted document, password supplied",
inputPath: plain,
values: map[string][]string{"password": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'page_1.docx' is not password-protected. Remove the 'password' form field.",
},
{
name: "inconclusive document, password supplied",
inputPath: legacy,
values: map[string][]string{"password": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not open the document 'legacy.doc' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.",
},
{
name: "malformed page ranges",
inputPath: plain,
values: map[string][]string{"nativePageRanges": {"foo"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.",
},
{
name: "password evidence outranks page ranges",
inputPath: protected,
values: map[string][]string{"nativePageRanges": {"1-2"}},
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusBadRequest,
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
},
{
name: "page ranges do not excuse a runtime exception",
inputPath: plain,
values: map[string][]string{"nativePageRanges": {"1-2"}},
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "nothing implicated, uno exception",
inputPath: plain,
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "nothing implicated, runtime exception",
inputPath: plain,
err: libreofficeapi.ErrRuntimeException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
},
{
name: "detection cannot read the document",
inputPath: unreachable,
err: libreofficeapi.ErrUnoException,
wantStatus: http.StatusInternalServerError,
wantBody: fmt.Sprintf(unattributableFailureMessage, "vanished.docx"),
},
{
name: "unreadable source",
inputPath: corrupted,
err: libreofficeapi.ErrIoException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
},
{
name: "rejected source",
inputPath: corrupted,
err: libreofficeapi.ErrIllegalArgumentException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
},
{
name: "unconvertible document",
inputPath: corrupted,
err: libreofficeapi.ErrCannotConvertException,
wantStatus: http.StatusBadRequest,
wantBody: "LibreOffice read the document 'corrupted.docx' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.",
},
{
name: "core dumped past the retry cap",
inputPath: plain,
err: libreofficeapi.ErrCoreDumped,
wantStatus: http.StatusInternalServerError,
wantBody: http.StatusText(http.StatusInternalServerError),
},
{
name: "unmapped exit code",
inputPath: plain,
err: fmt.Errorf("convert to PDF: exit status 7"),
wantStatus: http.StatusInternalServerError,
wantBody: http.StatusText(http.StatusInternalServerError),
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetDirPath(dir)
ctx.SetFiles(map[string]string{filepath.Base(tc.inputPath): tc.inputPath})
ctx.SetValues(tc.values)
ctx.SetLogger(slog.New(slog.DiscardHandler))
uno := &libreofficeapi.ApiMock{
ExtensionsMock: func() []string {
return []string{".docx", ".doc"}
},
PdfMock: func(_ context.Context, _ *slog.Logger, _, _ string, _ libreofficeapi.Options) error {
// Mirror the wrapping done by [libreofficeapi.Api.Pdf].
return fmt.Errorf("supervisor run task: %w", tc.err)
},
}
c := echo.New().NewContext(
httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", nil),
httptest.NewRecorder(),
)
c.Set("context", ctx.Context)
err := convertRoute(uno, new(gotenberg.PdfEngineMock)).Handler(c)
if err == nil {
t.Fatal("expected an error, got none")
}
status, message := api.ParseError(err)
if status != tc.wantStatus {
t.Errorf("status = %d, want %d (message: %s)", status, tc.wantStatus, message)
}
if message != tc.wantBody {
t.Errorf("message =\n%s\nwant\n%s", message, tc.wantBody)
}
})
}
}

View File

@@ -308,7 +308,10 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
defer span.End() defer span.End()
tmpPath := fmt.Sprintf("%s.read.json", inputPath) tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath} // --force: without it, a leftover file from an interrupted run makes pdfcpu
// refuse, and the stale contents would then be read as this document's
// bookmarks.
args := []string{"bookmarks", "export", "--force", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { if err != nil {
err = fmt.Errorf("create command: %w", err) err = fmt.Errorf("create command: %w", err)
@@ -456,7 +459,9 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
} }
}() }()
args := []string{"bookmarks", "import", "--replace", inputPath, tmpPath, inputPath} // --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{"bookmarks", "import", "--replace", "--force", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { if err != nil {
err = fmt.Errorf("create command: %w", err) err = fmt.Errorf("create command: %w", err)
@@ -559,8 +564,11 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
perm = "none" perm = "none"
} }
args := make([]string, 0, 11) args := make([]string, 0, 12)
args = append(args, "encrypt") args = append(args, "encrypt")
// --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args = append(args, "--force")
args = append(args, "--mode", "aes") args = append(args, "--mode", "aes")
args = append(args, "--upw", opts.UserPassword) args = append(args, "--upw", opts.UserPassword)
args = append(args, "--opw", ownerPassword) args = append(args, "--opw", ownerPassword)
@@ -633,7 +641,9 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath
) )
defer span.End() defer span.End()
args := []string{"rotate"} // --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{"rotate", "--force"}
if pages != "" { if pages != "" {
args = append(args, "--pages", pages) args = append(args, "--pages", pages)
} }
@@ -679,7 +689,9 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
} }
description := strings.Join(descParts, ", ") description := strings.Join(descParts, ", ")
args := []string{command, "add", "--mode", mode} // --force: the output path is the input path, and pdfcpu refuses to
// overwrite an existing file without it.
args := []string{command, "add", "--mode", mode, "--force"}
if stamp.Pages != "" { if stamp.Pages != "" {
args = append(args, "--pages", stamp.Pages) args = append(args, "--pages", stamp.Pages)

View File

@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
startTime: startTime, startTime: startTime,
client: &retryablehttp.Client{ client: &retryablehttp.Client{
HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...), HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, w.enableEnvironmentProxy, ipOpts...),
RetryMax: w.maxRetry, RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait, RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait, RetryWaitMax: w.retryMaxWait,

View File

@@ -1,6 +1,7 @@
package webhook package webhook
import ( import (
"fmt"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -18,19 +19,20 @@ func init() {
// Webhook is a module that provides a middleware for uploading output files // Webhook is a module that provides a middleware for uploading output files
// to any destinations in an asynchronous fashion. // to any destinations in an asynchronous fashion.
type Webhook struct { type Webhook struct {
enableSyncMode bool enableSyncMode bool
allowList []*regexp2.Regexp allowList []*regexp2.Regexp
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
errorAllowList []*regexp2.Regexp errorAllowList []*regexp2.Regexp
errorDenyList []*regexp2.Regexp errorDenyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
maxRetry int enableEnvironmentProxy bool
retryMinWait time.Duration maxRetry int
retryMaxWait time.Duration retryMinWait time.Duration
clientTimeout time.Duration retryMaxWait time.Duration
asyncCount atomic.Int64 clientTimeout time.Duration
disable bool asyncCount atomic.Int64
disable bool
} }
// Descriptor returns an [Webhook]'s module descriptor. // Descriptor returns an [Webhook]'s module descriptor.
@@ -44,6 +46,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values") fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services") fs.Bool("webhook-deny-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.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.Bool("webhook-enable-environment-proxy", false, "Route webhook callbacks through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature") fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
// Deprecated flags. // Deprecated flags.
@@ -78,6 +81,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error {
w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list") w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips") w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips") w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips")
w.enableEnvironmentProxy = flags.MustBool("webhook-enable-environment-proxy")
w.maxRetry = flags.MustInt("webhook-max-retry") w.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait") w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait") w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")
@@ -104,10 +108,25 @@ func (w *Webhook) AsyncCount() int64 {
return w.asyncCount.Load() return w.asyncCount.Load()
} }
// Validate checks the module's configuration.
func (w *Webhook) Validate() error {
if !w.enableEnvironmentProxy {
return nil
}
err := gotenberg.ValidateEnvironmentProxyVariables()
if err != nil {
return fmt.Errorf("--webhook-enable-environment-proxy is set: %w", err)
}
return nil
}
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*Webhook)(nil) _ gotenberg.Module = (*Webhook)(nil)
_ gotenberg.Provisioner = (*Webhook)(nil) _ gotenberg.Provisioner = (*Webhook)(nil)
_ gotenberg.Validator = (*Webhook)(nil)
_ api.MiddlewareProvider = (*Webhook)(nil) _ api.MiddlewareProvider = (*Webhook)(nil)
_ api.AsynchronousCounter = (*Webhook)(nil) _ api.AsynchronousCounter = (*Webhook)(nil)
) )

View File

@@ -548,6 +548,25 @@ Feature: /forms/chromium/convert/html
Error: Exception 2 Error: Exception 2
""" """
Scenario: POST /forms/chromium/convert/html (Document Outline)
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-outline-html/index.html | file |
| generateDocumentOutline | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
# generateTaggedPdf is left unset, yet the outline must still be embedded:
# Gotenberg enables tagged PDF automatically because Chromium derives the
# outline from the structure tree. See issue #1579.
Then the "foo.pdf" PDF should have a document outline
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-outline-html/index.html | file |
| Gotenberg-Output-Filename | bar | header |
Then the response status code should be 200
Then the "bar.pdf" PDF should NOT have a document outline
Scenario: POST /forms/chromium/convert/html (Bad Request) Scenario: POST /forms/chromium/convert/html (Bad Request)
Given I have a default Gotenberg container Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -794,8 +813,40 @@ Feature: /forms/chromium/convert/html
""" """
# See https://github.com/gotenberg/gotenberg/issues/1130. # See https://github.com/gotenberg/gotenberg/issues/1130.
# A backslash is not a path separator on Linux, so filepath.Base leaves it in
# place and it reaches the archive entry names. See GHSA-hwc4-gmrw-5222.
@split @split
@output-filename @output-filename
Scenario: POST /forms/chromium/convert/html (Split Windows Path As Output Filename)
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/pages-3-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| Gotenberg-Output-Filename | ..\\..\\..\\Windows\\System32\\foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.zip |
| foo_0.pdf |
| foo_1.pdf |
Scenario: POST /forms/chromium/convert/html (Split Rooted Windows Path As Output Filename)
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/pages-3-html/index.html | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.zip |
| foo_0.pdf |
| foo_1.pdf |
Scenario: POST /forms/chromium/convert/html (Split Output Filename) Scenario: POST /forms/chromium/convert/html (Split Output Filename)
Given I have a default Gotenberg container Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):

View File

@@ -88,7 +88,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
""" """
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain. The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.
""" """
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/protected_page_1.docx | file | | files | testdata/protected_page_1.docx | file |
@@ -255,7 +255,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
""" """
LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain. LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.
""" """
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file | | files | testdata/page_1.docx | file |
@@ -264,7 +264,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
""" """
LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain. The document 'page_1.docx' is not password-protected. Remove the 'password' form field.
""" """
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/protected_page_1.docx | file | | files | testdata/protected_page_1.docx | file |
@@ -273,7 +273,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "text/plain; charset=UTF-8" Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string: Then the response body should match string:
""" """
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain. The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.
""" """
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file | | files | testdata/page_1.docx | file |

View File

@@ -22,6 +22,17 @@ Feature: Output Filename
Then there should be the following file(s) in the response: Then there should be the following file(s) in the response:
| foo.zip | | foo.zip |
# See GHSA-hwc4-gmrw-5222.
Scenario: Windows Path As Filename
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be the following file(s) in the response:
| foo.pdf |
# See https://github.com/gotenberg/gotenberg/issues/1227. # See https://github.com/gotenberg/gotenberg/issues/1227.
Scenario: Path As Filename Scenario: Path As Filename
Given I have a default Gotenberg container Given I have a default Gotenberg container

View File

@@ -1077,6 +1077,60 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
return nil return nil
} }
func (s *scenario) thePdfShouldHaveADocumentOutline(ctx context.Context, name, kind string) error {
var path string
if !strings.HasPrefix(name, "*_") {
path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name)
_, err := os.Stat(path)
if os.IsNotExist(err) {
return fmt.Errorf("PDF %q does not exist", path)
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.Contains(info.Name(), substr) {
path = currentPath
return filepath.SkipDir
}
return nil
})
if err != nil {
return fmt.Errorf("walk %q: %w", s.workdir, err)
}
}
cmd := []string{
"verapdf",
"-off",
"--extract",
"outlines",
filepath.Base(path),
}
output, err := execCommandInIntegrationToolsContainer(ctx, cmd, path)
if err != nil {
return fmt.Errorf("exec %q: %w", cmd, err)
}
// veraPDF emits an empty features report when the catalog holds no outline.
hasOutline := !strings.Contains(output, "<featuresReport></featuresReport>")
invert := kind == "should NOT"
if !invert && !hasOutline {
return fmt.Errorf("PDF %q has no document outline", path)
}
if invert && hasOutline {
return fmt.Errorf("PDF %q has a document outline", path)
}
return nil
}
// pdfPageText extracts the text of a single page from a produced PDF using // pdfPageText extracts the text of a single page from a produced PDF using
// pdftotext. name is either a literal filename or a "*_" glob resolved against // pdftotext. name is either a literal filename or a "*_" glob resolved against
// the test store. // the test store.
@@ -1541,6 +1595,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel) ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) 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) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have a document outline$`, s.thePdfShouldHaveADocumentOutline)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage)
ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have content matching "([^"]*)" at page (\d+)$`, s.thePdfShouldHaveContentMatchingAtPage)
ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages) ctx.Then(`^the "([^"]*)" PDF should have (\d+) image\(s\)$`, s.thePdfShouldHaveImages)

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<title>Outline</title>
</head>
<body>
<h1>Chapter 1</h1>
<p>Intro.</p>
<h2>Section 1.1</h2>
<p>Body.</p>
<h1>Chapter 2</h1>
<p>Intro.</p>
<h2>Section 2.1</h2>
<p>Body.</p>
</body>
</html>