diff --git a/pkg/gotenberg/outbound.go b/pkg/gotenberg/outbound.go index 8f8643fb..5f52e1cb 100644 --- a/pkg/gotenberg/outbound.go +++ b/pkg/gotenberg/outbound.go @@ -79,6 +79,18 @@ var nonPublicIPv6Prefixes = []netip.Prefix{ netip.MustParsePrefix("100::/64"), } +// nonPublicIPv4Prefixes lists IPv4 ranges that the [netip.Addr] helpers do +// not classify but that must not be considered public: +// +// - 100.64.0.0/10 Carrier-grade NAT (RFC 6598). Routable inside provider +// and cluster networks, and Alibaba Cloud serves instance metadata from +// 100.100.100.200. +// - 198.18.0.0/15 Benchmarking (RFC 2544). Never routed on the internet. +var nonPublicIPv4Prefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("198.18.0.0/15"), +} + // IsPublicIP reports whether addr is reachable on the public internet. It // returns false for loopback, private (RFC1918), link-local, unspecified, // multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are @@ -89,7 +101,8 @@ var nonPublicIPv6Prefixes = []netip.Prefix{ // (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into, // because a host that routes them implicitly trusts the IPv4 mapping and // the prefixes themselves are deprecated or translation-only. See -// [nonPublicIPv6Prefixes] for the full list and rationale. +// [nonPublicIPv6Prefixes] and [nonPublicIPv4Prefixes] for the full lists +// and rationale. func IsPublicIP(addr netip.Addr) bool { if !addr.IsValid() { return false @@ -105,6 +118,13 @@ func IsPublicIP(addr netip.Addr) bool { addr.IsInterfaceLocalMulticast(): return false } + if addr.Is4() { + for _, p := range nonPublicIPv4Prefixes { + if p.Contains(addr) { + return false + } + } + } if addr.Is6() { for _, p := range nonPublicIPv6Prefixes { if p.Contains(addr) { diff --git a/pkg/gotenberg/outbound_test.go b/pkg/gotenberg/outbound_test.go index 52791317..d4edd067 100644 --- a/pkg/gotenberg/outbound_test.go +++ b/pkg/gotenberg/outbound_test.go @@ -40,6 +40,24 @@ func TestIsPublicIP(t *testing.T) { // Link-local. {"169.254.169.254", false}, + {"169.254.170.2", false}, + + // Carrier-grade NAT (RFC 6598). Alibaba Cloud serves instance + // metadata from 100.100.100.200. + {"100.64.0.0", false}, + {"100.100.100.200", false}, + {"100.127.255.255", false}, + {"::ffff:100.100.100.200", false}, + + // Benchmarking (RFC 2544). + {"198.18.0.1", false}, + {"198.19.255.255", false}, + + // Adjacent to the ranges above, and public. + {"100.63.255.255", true}, + {"100.128.0.0", true}, + {"198.17.255.255", true}, + {"198.20.0.0", true}, {"fe80::1", false}, // Unique-local. diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 398f4e82..a97c03e4 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -120,6 +120,30 @@ func (engine *QPdf) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue return append(attrs, extra...) } +// qpdfPageRange matches the page range syntax qpdf accepts, and nothing else. +// +// A term is a page number, "z" for the last page, or "rN" counting from the +// end, optionally prefixed with "x" to exclude it. Terms combine into ranges +// with "-", ranges join with ",", and the whole thing takes an optional ":odd" +// or ":even". +var qpdfPageRange = regexp.MustCompile(`^x?(?:z|r\d+|\d+)(?:-x?(?:z|r\d+|\d+))?(?:,x?(?:z|r\d+|\d+)(?:-x?(?:z|r\d+|\d+))?)*(?::odd|:even)?$`) + +// validateSplitSpan returns span when it is a qpdf page range. +// +// qpdf reads the argument after "--pages ." as either a page range or another +// source file, so a span carrying a path makes qpdf append that file's pages +// to the output. Other engines in the split chain accept spellings qpdf does +// not, such as pdfcpu's "2-end", so a span this rejects is not necessarily +// invalid. Returning an error lets the chain move on to an engine that +// understands it. +func validateSplitSpan(span string) error { + if qpdfPageRange.MatchString(span) { + return nil + } + + return fmt.Errorf("split span '%s' is not a QPDF page range: %w", span, gotenberg.ErrPdfSplitModeNotSupported) +} + // Split splits a given PDF file. func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split", @@ -139,6 +163,12 @@ func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenbe span.SetStatus(codes.Error, err.Error()) return nil, err } + err := validateSplitSpan(mode.Span) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } args = append(args, inputPath) args = append(args, engine.globalArgs...) args = append(args, "--pages", ".", mode.Span) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go index c5f7cbe3..9bea5973 100644 --- a/pkg/modules/qpdf/qpdf_test.go +++ b/pkg/modules/qpdf/qpdf_test.go @@ -3,9 +3,12 @@ package qpdf import ( "context" "encoding/json" + "errors" "log/slog" "os" "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) func TestStripQpdfStringPrefix(t *testing.T) { @@ -270,3 +273,54 @@ func TestSetStreamSubtype(t *testing.T) { } }) } + +func TestValidateSplitSpan(t *testing.T) { + for _, tc := range []struct { + span string + valid bool + }{ + // qpdf page ranges. + {"1", true}, + {"12", true}, + {"1-5", true}, + {"2-z", true}, + {"z", true}, + {"r1", true}, + {"r3-r1", true}, + {"1,3,5-9", true}, + {"1-5,x3", true}, + {"1-z:odd", true}, + {"1-z:even", true}, + + // Other engines' spellings. Not valid here, so the chain moves on. + {"2-end", false}, + {"2-", false}, + {"foo", false}, + + // A span qpdf would read as a source file. + {"/tmp/secret.pdf", false}, + {"secret.pdf", false}, + {"./secret.pdf", false}, + {"../../etc/hosts", false}, + {"1,/tmp/secret.pdf", false}, + {"1 /tmp/secret.pdf", false}, + {"", false}, + {"--password=x", false}, + } { + t.Run(tc.span, func(t *testing.T) { + err := validateSplitSpan(tc.span) + if tc.valid && err != nil { + t.Fatalf("validateSplitSpan(%q) = %v, want nil", tc.span, err) + } + if !tc.valid { + if err == nil { + t.Fatalf("validateSplitSpan(%q) = nil, want an error", tc.span) + } + // The chain must be able to try the next engine. + if !errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) { + t.Fatalf("error %v does not wrap ErrPdfSplitModeNotSupported", err) + } + } + }) + } +}