From: CPANSec Security Scanner Bot <cpan-security@security.metacpan.org>
Subject: [PATCH] PDF::WebKit: allow-list options taken from HTML <meta> tags

BUILD extracts wkhtmltopdf options from <meta name="pdf-webkit-*"> tags
in the source document and forwards them to wkhtmltopdf with no filter,
merged last so they override the application's own options. When the
document body is attacker-controlled this is argument injection: a meta
tag can enable --enable-local-file-access (defeating wkhtmltopdf's
file-access restriction), set --cookie-jar/--dump-outline (file write),
etc.

This restricts meta tag options to an allow-list of presentational
switches (page geometry, margins, encoding, zoom, header and footer
text, ...). Both halves of the tag are checked, because _prepare_options
emits the content attribute as an argv element of its own: the option
name must be on the list, and the value must be one the list permits for
that option. A switch that takes no argument accepts only "yes", so it
cannot carry a payload; every other option accepts only values matching
its pattern, and never "yes", which would emit it as a bare switch that
consumes the argument after it. Anything else is dropped with a warning
rather than silently. Meta tag options are also merged before the
options passed to new() instead of after, so document content can no
longer override the application's own settings. The list is a new
PDF::WebKit::Configuration attribute, so an operator can widen it
explicitly via PDF::WebKit->configure.

diff --git a/lib/PDF/WebKit.pm b/lib/PDF/WebKit.pm
index 5e83e5b..8bf1d0e 100755
--- a/lib/PDF/WebKit.pm
+++ b/lib/PDF/WebKit.pm
@@ -36,10 +36,13 @@ sub BUILD {
 
   $self->source( PDF::WebKit::Source->new($args->{source}) );
   $self->stylesheets( [] );
+  # SECURITY: meta tag options come from the (untrusted) document body,
+  # so they are merged before the options the application passed to
+  # new() rather than after, and can no longer override them.
   $self->_set_options({
     $self->_normalize_options(%{ $self->configuration->default_options }),
-    $self->_normalize_options(%{ $args->{options} }),
     $self->_normalize_options($self->_find_options_in_meta),
+    $self->_normalize_options(%{ $args->{options} }),
   });
 
   if (not -x $self->configuration->wkhtmltopdf) {
@@ -123,9 +126,15 @@ sub to_file {
 sub _find_options_in_meta {
   my ($self) = @_;
   return () if $self->source->is_url;
-  # if we can't parse for whatever reason, keep calm and carry on.
+  # if we can't parse for whatever reason, keep calm and carry on, but
+  # say so: every meta tag option is dropped when this fails, and a
+  # misconfigured allowed_meta_options lands here too.
   my @result = eval { $self->_pdf_webkit_meta_tags };
-  return $@ ? () : @result;
+  if ($@) {
+    Carp::carp "PDF::WebKit: meta tag options ignored: $@";
+    return ();
+  }
+  return @result;
 }
 
 sub _pdf_webkit_meta_tags {
@@ -149,16 +158,51 @@ sub _pdf_webkit_meta_tags {
           : $source->is_file ? $parser->parse_html_file($source->string,\%options)
           : return ();
 
+  my $allowed = $self->configuration->allowed_meta_options;
+  Carp::croak "allowed_meta_options must be a hash reference"
+    unless ref($allowed) eq 'HASH';
+
   my %meta;
   for my $node ($doc->findnodes('html/head/meta')) {
     my $name = $node->getAttribute('name');
     next unless ($name && ($name =~ s{^\Q$prefix}{}s));
-    $meta{$name} = $node->getAttribute('content');
+    # SECURITY: the document body is untrusted, so the option name and
+    # its value are both checked against the allow-list. An unlisted
+    # option would let document content drive the renderer
+    # (--enable-local-file-access, --cookie-jar, --dump-outline, ...);
+    # an unexpected value would either reach wkhtmltopdf as an argument
+    # of its own, since _prepare_options emits the value as a separate
+    # argument, or turn a value taking option into a bare switch that
+    # consumes the argument after it. See
+    # PDF::WebKit::Configuration::allowed_meta_options.
+    my $rule = $allowed->{ $self->_normalize_arg($name) };
+    if (!defined $rule) {
+      Carp::carp "PDF::WebKit: <meta> option '$name' is not in allowed_meta_options, ignored";
+      next;
+    }
+    my $value = $node->getAttribute('content');
+    $value = '' unless defined $value;
+    if (!$self->_meta_option_value_ok($rule,$value)) {
+      Carp::carp "PDF::WebKit: <meta> option '$name' has a value that allowed_meta_options does not permit, ignored";
+      next;
+    }
+    $meta{$name} = $value;
   }
 
   return %meta;
 }
 
+sub _meta_option_value_ok {
+  my ($self,$rule,$value) = @_;
+  # a switch taking no argument is only settable as "yes"; a value
+  # taking option must match its pattern in full, and must not be "yes"
+  # either, which _prepare_options would emit as a bare switch.
+  return lc($value) eq 'yes' if !ref($rule);
+  return 0 unless ref($rule) eq 'Regexp';
+  return 0 if lc($value) eq 'yes';
+  return $value =~ $rule ? 1 : 0;
+}
+
 sub _style_tag_for {
   my ($self,$stylesheet) = @_;
   my $styles = do { local (@ARGV,$/) = ($stylesheet); <> };
@@ -245,8 +289,8 @@ PDF::WebKit - Use WebKit to Generate PDFs from HTML (via wkhtmltopdf)
   my $kit = PDF::WebKit->new('http://google.com');
   my $kit = PDF::WebKit->new('/path/to/html');
 
-  # Add any kind of option through meta tags
-  my $kit = PDF::WebKit->new(\'<html><head><meta name="pdfkit-page_size" content="Letter"...');
+  # Add an allow-listed option through meta tags
+  my $kit = PDF::WebKit->new(\'<html><head><meta name="pdf-webkit-page_size" content="Letter"...');
 
 =head1 DESCRIPTION
 
@@ -271,10 +315,34 @@ C<< PDF::WebKit->configure >> class method:
     $_->meta_tag_prefix('my-prefix-');
 
     $_->default_options->{'--orientation'} = 'Portrait';
+
+    # default: an allow-list of presentational options only
+    $_->allowed_meta_options->{'--disable-javascript'} = 'flag';
   });
 
 See the L<new|/Constructor> method for the standard default options.
 
+=head2 Options in meta tags
+
+An HTML string or file source may set wkhtmltopdf options through
+C<< <meta name="pdf-webkit-KEY" content="VALUE"> >> elements in the
+document head. The document body is untrusted input in most
+applications, so only the options in the C<allowed_meta_options>
+configuration attribute may be set that way, and each one accepts only
+the values that attribute permits: a switch taking no argument accepts
+C<"yes">, and any other option accepts values matching its pattern.
+Options set by the application through C<new> take precedence over
+options set in meta tags. Anything else found in a meta tag is dropped
+with a warning.
+
+The default list covers presentational options: page geometry, margins,
+orientation, encoding, zoom, resolution, smart shrinking, background,
+grayscale, print media type, title, minimum font size, viewport size,
+JavaScript delay, and the header and footer text, spacing, line and
+font options. Options that name a file or a URL, and options that relax
+the renderer's restrictions, are not included. C<< PDF::WebKit->configure >>
+can widen the list where an application trusts its documents.
+
 =head2 Constructor
 
 =over 4
diff --git a/lib/PDF/WebKit/Configuration.pm b/lib/PDF/WebKit/Configuration.pm
index f561f45..e5d48f4 100755
--- a/lib/PDF/WebKit/Configuration.pm
+++ b/lib/PDF/WebKit/Configuration.pm
@@ -4,15 +4,68 @@ use warnings;
 use Moo;
 use namespace::clean;
 
-has meta_tag_prefix => ( is => 'rw' );
-has default_options => ( is => 'rw' );
+has meta_tag_prefix      => ( is => 'rw' );
+has default_options      => ( is => 'rw' );
+has allowed_meta_options => ( is => 'rw' );
 has wkhtmltopdf     => ( is => 'rw', builder => '_find_wkhtmltopdf', lazy => 1 );
 
+# SECURITY: options taken from <meta> tags come from the (untrusted)
+# document body, so the option name and its value are both constrained.
+# Each entry is keyed by the normalized "--name" form, and is either the
+# string "flag" (a switch taking no argument, whose content attribute
+# must be "yes") or a regular expression the content attribute must
+# match in full. Options that read or write a file or a URL
+# (--cookie-jar, --dump-outline, --header-html, --user-style-sheet,
+# ...) and options that relax the renderer's restrictions (--allow,
+# --enable-local-file-access, script and network switches, ...) are
+# deliberately absent: document content must not be able to set them.
+# Operators who need a wider list may replace this via
+# PDF::WebKit->configure.
+my $META_LENGTH = qr{[0-9]{1,4}(?:\.[0-9]{1,3})?(?:in|mm|cm|pt|px)?};
+my $META_NUMBER = qr{[0-9]{1,4}(?:\.[0-9]{1,4})?};
+my $META_INT    = qr{[0-9]{1,6}};
+my $META_TEXT   = qr{[^\x00-\x1f-][^\x00-\x1f]{0,127}};
+my $META_NAME   = qr{[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}};
+
+my %ALLOWED_META_OPTIONS = (
+  ( map +( $_ => 'flag' ), qw(
+      --grayscale --lowquality --background --no-background
+      --disable-smart-shrinking --enable-smart-shrinking
+      --print-media-type --no-print-media-type
+      --header-line --no-header-line --footer-line --no-footer-line
+  ) ),
+  ( map +( $_ => qr{\A$META_LENGTH\z} ), qw(
+      --page-width --page-height
+      --margin-top --margin-right --margin-bottom --margin-left
+  ) ),
+  ( map +( $_ => qr{\A$META_NUMBER\z} ), qw(
+      --zoom --header-spacing --footer-spacing
+  ) ),
+  ( map +( $_ => qr{\A$META_INT\z} ), qw(
+      --dpi --image-dpi --image-quality --minimum-font-size
+      --header-font-size --footer-font-size
+      --javascript-delay --page-offset
+  ) ),
+  ( map +( $_ => qr{\A$META_TEXT\z} ), qw(
+      --title
+      --header-left --header-center --header-right
+      --footer-left --footer-center --footer-right
+  ) ),
+  ( map +( $_ => qr{\A$META_NAME\z} ), qw(
+      --header-font-name --footer-font-name
+  ) ),
+  '--page-size'     => qr{\A[A-Za-z0-9]{1,16}\z},
+  '--orientation'   => qr{\A(?i:Portrait|Landscape)\z},
+  '--encoding'      => qr{\A[A-Za-z0-9][A-Za-z0-9._:-]{0,31}\z},
+  '--viewport-size' => qr{\A[0-9]{1,5}x[0-9]{1,5}\z},
+);
+
 around 'BUILDARGS' => sub {
   my $orig = shift;
   my $self = shift;
   return $self->$orig({
     meta_tag_prefix => 'pdf-webkit-',
+    allowed_meta_options => { %ALLOWED_META_OPTIONS },
     default_options => {
       disable_smart_shrinking => undef,
       page_size => 'Letter',
