Browse Source

content security policy, URI san and RSS stylin'

George Baugh 2 years ago
parent
commit
c5a31b442a

+ 1 - 1
Makefile.PL

@@ -58,7 +58,7 @@ WriteMakefile(
     'Log::Dispatch::FileRotate' => '0',
     'Digest::SHA'               => '0',
     'MIME::Base32::XS'          => '0',
-    'URI::XS'                   => '0',
+    'URI'                       => '0',
   },
   test => {TESTS => 't/*.t'}
 );

+ 2 - 0
config/default.cfg

@@ -3,3 +3,5 @@
     title=tCMS
 [totp]
     secret=OverrideMeInYourConfigPlease!
+[security]
+    allow_embeds_from=vimeo.com *.vimeo.com youtube.com *.youtube.com www.youtube-nocookie.com

+ 5 - 4
lib/TCMS.pm

@@ -22,7 +22,7 @@ use Time::HiRes      qw{gettimeofday tv_interval};
 use HTTP::Parser::XS qw{HEADERS_AS_HASHREF};
 use List::Util;
 use UUID::Tiny();
-use URI::XS();
+use URI();
 
 #Grab our custom routes
 use lib 'lib';
@@ -147,8 +147,9 @@ sub app {
     $query->{user_acls} = [];
     $query->{user_acls} = Trog::Auth::acls4user($active_user) // [] if $active_user;
 
-    # Log the request.
-    Trog::Log::uuid(UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS ));
+    # Log the request.  UUID::Tiny can explode sometimes.
+    my $requestid = eval { UUID::Tiny::create_uuid_as_string( UUID::Tiny::UUID_V1, UUID::Tiny::UUID_NS_DNS ) } // "00000000-0000-0000-0000-000000000000";
+    Trog::Log::uuid($requestid);
     INFO("$env->{REQUEST_METHOD} $path");
 
     # Filter out passed ACLs which are naughty
@@ -227,7 +228,7 @@ sub app {
     $query->{primary_post} = {};
     $query->{has_query}    = $has_query;
     # Redirecting somewhere naughty not allow
-    $query->{to}           = URI::XS->new($query->{to})->path;
+    $query->{to}           = URI->new($query->{to} // '')->path() || $query->{to} if $query->{to};
 
     #XXX there is a trick to now use strict refs, but I don't remember it right at the moment
     {

+ 68 - 4
lib/Trog/Routes/HTML.pm

@@ -18,6 +18,7 @@ use IO::Compress::Gzip;
 use CSS::Minifier::XS;
 use Path::Tiny();
 use File::Basename qw{dirname};
+use URI();
 
 use Trog::Log qw{:all};
 use Trog::Utils;
@@ -181,6 +182,15 @@ our %routes = (
         method   => 'GET',
         callback => \&Trog::Routes::HTML::icon,
     },
+    '/styles/rss-style.xsl' => {
+        method => 'GET',
+        callback => \&Trog::Routes::HTML::rss_style,
+    },
+    '/rss.xml' => {
+        method   => 'GET',
+        callback => \&Trog::Routes::HTML::posts,
+        data     => { format => 'rss' },
+    }
 );
 
 # Grab theme routes
@@ -402,6 +412,7 @@ sub setup ($query) {
         {
             title       => 'tCMS Requires Setup to Continue...',
             stylesheets => _build_themed_styles('notconfigured.css'),
+            scheme      => $query->{scheme},
         }
     );
 }
@@ -428,6 +439,7 @@ sub totp ($query) {
             failure     => $failure,
             message     => $message,
             stylesheets => _build_themed_styles('post.css'),
+            scheme      => $query->{scheme},
         }
     );
 }
@@ -494,6 +506,7 @@ sub login ($query) {
             btnmsg      => $btnmsg,
             stylesheets => _build_themed_styles('login.css'),
             theme_dir   => $td,
+            scheme      => $query->{scheme},
         },
         @headers
     );
@@ -611,6 +624,8 @@ sub config ($query) {
             message            => $query->{message},
             failure            => $query->{failure},
             to                 => '/config',
+            scheme             => $query->{scheme},
+            embeds             => $conf->param('security.allow_embeds_from') // '',
         }
     );
 }
@@ -873,6 +888,11 @@ Display multi or single posts, supports RSS and pagination.
 
 sub posts ( $query, $direct = 0 ) {
 
+    # Allow rss.xml to tell what posts to loop over
+    my $fmt = $query->{format} || '';
+    my $for = URI->new($query->{for} || '')->path() || $query->{route};
+    $query->{route} = $for if $fmt eq 'rss';
+
     #Process the input URI to capture tag/id
     $query->{route} //= $query->{to};
     my ( undef, undef, $id ) = split( /\//, $query->{route} );
@@ -924,7 +944,6 @@ sub posts ( $query, $direct = 0 ) {
     # Set the eTag so that we don't get a re-fetch
     $query->{etag} = "$posts[0]{id}-$posts[0]{version}" if @posts;
 
-    my $fmt = $query->{format} || '';
     return _rss( $query, \@posts ) if $fmt eq 'rss';
 
     #XXX Is used by the sitemap, maybe just fix there?
@@ -1241,11 +1260,11 @@ sub sitemap ($query) {
 
 sub _rss ( $query, $posts ) {
     require XML::RSS;
-    my $rss = XML::RSS->new( version => '2.0' );
+    my $rss = XML::RSS->new( version => '2.0', stylesheet => '/styles/rss-style.xsl' );
     my $now = DateTime->from_epoch( epoch => time() );
     $rss->channel(
         title         => "$query->{domain}",
-        link          => "http://$query->{domain}/$query->{route}?format=rss",
+        link          => "http://$query->{domain}/$query->{route}.rss.xml",
         language      => 'en',                                                   #TODO localization
         description   => "$query->{domain} : $query->{route}",
         pubDate       => $now,
@@ -1271,7 +1290,16 @@ sub _rss ( $query, $posts ) {
         }
     }
 
-    return finish_render( undef, { etag => $query->{etag}, contenttype => "application/rss+xml", body => encode_utf8( $rss->as_string ) } );
+    return finish_render(
+        undef,
+        {
+            etag => $query->{etag},
+            contenttype => "application/rss+xml",
+            body => encode_utf8( $rss->as_string ),
+            scheme => $query->{scheme}
+        },
+        'Content-Disposition' => 'inline; filename="rss.xml"',
+    );
 }
 
 sub _post2rss ( $rss, $url, $post ) {
@@ -1315,6 +1343,7 @@ sub manual ($query) {
             theme_dir   => $td,
             content     => $content,
             stylesheets => _build_themed_styles('post.css'),
+            scheme      => $query->{scheme},
         }
     );
 }
@@ -1453,6 +1482,20 @@ sub finish_render ( $template, $vars, %headers ) {
     $headers{$cc}              = $vars->{cachecontrol} if $vars->{cachecontrol};
     $headers{'Vary'}           = 'Accept-Encoding';
     $headers{"Content-Length"} = length($body);
+    $headers{'X-Content-Type-Options'} = 'nosniff';
+    $headers{'X-Frame-Options'} = 'DENY' unless $vars->{embed};
+    $headers{'Referrer-Policy'} = 'no-referrer-when-downgrade';
+
+    # Force loading of https only resources from this host.
+    my $scheme = $vars->{scheme} ? "$vars->{scheme}:" : '';
+    $headers{'Content-Security-Policy'} .= ";default-src '$scheme' 'self'";
+
+    # Allow video embeds from the big boys
+    my $sites = $conf->param('security.allow_embeds_from') // '';
+    $headers{'Content-Security-Policy'} .= qq{;frame-src 'self' $sites;child-src 'self' $sites; script-src 'self' $sites};
+
+    # Force https if we are https
+    $headers{'Strict-Transport-Security'} = 'max-age=63072000';
 
     # We only set etags when users are logged in, cause we don't use statics
     $headers{'ETag'} = $vars->{etag} if $vars->{etag} && $vars->{user};
@@ -1499,4 +1542,25 @@ sub icon ($query) {
     return Trog::FileHandler::serve("$td/img/icon/$path");
 }
 
+# TODO make statics, abstract gzipped outputting & header handling
+sub rss_style ($query) {
+    my $body = encode_utf8(themed_render('rss-style.tx', $query));
+    my %headers = (
+        'Content-Type'   => 'text/xsl',
+        'Content-Length' => length($body),
+        'Cache-Control'  => $Trog::Vars::cache_control{revalidate},
+        'X-Content-Type-Options' => 'nosniff',
+        'Vary'           => 'Accept-Encoding',
+    );
+    return [ 200, [%headers], [$body]] unless $query->{deflate};
+
+    $headers{"Content-Encoding"} = "gzip";
+    my $dfh;
+    IO::Compress::Gzip::gzip( \$body => \$dfh );
+    print $IO::Compress::Gzip::GzipError if $IO::Compress::Gzip::GzipError;
+    $headers{"Content-Length"} = length($dfh);
+
+    return [ 200, [%headers], [$dfh] ];
+}
+
 1;

+ 4 - 0
www/templates/config.tx

@@ -28,6 +28,10 @@ If for example, you use mysql it will have to rely on either a local server, val
     Global TOTP Secret:
     <input class="cooltext" type="text" name="totp_secret" value="<: $totp_secret :>" />
     </div>
+    <div>
+    Allow Embeds from:
+    <input class="cooltext" type="textarea" name="embeds" value="<: $embeds :>" />
+    </div>
     <br />
     <input type="submit" class="coolbutton" value="Commit Changes" />
 </form>

+ 1 - 1
www/templates/index.tx

@@ -19,7 +19,7 @@
     : if ($user) {
     <a href="/config" title="Preferences" class="topbar topbarimg usericon <: $user :>"></a>
     : } else {
-    <a href="/login" title="Login" class="topbar usericon" style="font-size: 1.5rem;">🔑</a>
+    <a href="/login?to=<: $route :>" title="Login" class="topbar usericon" style="font-size: 1.5rem;">🔑</a>
     : }
 </div>
 <div id="littlemenu">

+ 1 - 1
www/templates/notconfigured.tx

@@ -16,6 +16,6 @@
         </a>
         for full instructions on how configure tCMS.
         <br /><br />
-        Please <a href="/login" alt="Login">Log In</a> and Configure tCMS.
+        Please <a href="/login?to=/config" alt="Login">Log In</a> and Configure tCMS.
     </p>
 </section>

+ 94 - 0
www/templates/rss-style.tx

@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="utf-8"?>
+<xsl:stylesheet version="3.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+                xmlns:atom="http://www.w3.org/2005/Atom">
+  <xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes"/>
+  <xsl:template match="/">
+    <html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+      <head>
+        <title>
+          RSS Feed |
+          <xsl:value-of select="/atom:feed/atom:title"/>
+        </title>
+        <meta charset="utf-8"/>
+        <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
+        <meta name="viewport" content="width=device-width, initial-scale=1"/>
+        <link rel="stylesheet" href="/assets/styles.css"/>
+      </head>
+      <body>
+        <main class="layout-content">
+          <dk-alert-box type="info">
+            <strong>This is an RSS feed</strong>. Subscribe by copying
+            the URL from the address bar into your newsreader. Visit <a
+            href="https://aboutfeeds.com">About Feeds
+          </a> to learn more and get started. It’s free.
+          </dk-alert-box>
+          <div class="py-7">
+            <h1 class="flex items-start">
+              <!-- https://commons.wikimedia.org/wiki/File:Feed-icon.svg -->
+              <svg xmlns="http://www.w3.org/2000/svg" version="1.1"
+                   class="mr-5"
+                   style="flex-shrink: 0; width: 1em; height: 1em;"
+                   viewBox="0 0 256 256">
+                <defs>
+                  <linearGradient x1="0.085" y1="0.085" x2="0.915" y2="0.915"
+                                  id="RSSg">
+                    <stop offset="0.0" stop-color="#E3702D"/>
+                    <stop offset="0.1071" stop-color="#EA7D31"/>
+                    <stop offset="0.3503" stop-color="#F69537"/>
+                    <stop offset="0.5" stop-color="#FB9E3A"/>
+                    <stop offset="0.7016" stop-color="#EA7C31"/>
+                    <stop offset="0.8866" stop-color="#DE642B"/>
+                    <stop offset="1.0" stop-color="#D95B29"/>
+                  </linearGradient>
+                </defs>
+                <rect width="256" height="256" rx="55" ry="55" x="0" y="0"
+                      fill="#CC5D15"/>
+                <rect width="246" height="246" rx="50" ry="50" x="5" y="5"
+                      fill="#F49C52"/>
+                <rect width="236" height="236" rx="47" ry="47" x="10" y="10"
+                      fill="url(#RSSg)"/>
+                <circle cx="68" cy="189" r="24" fill="#FFF"/>
+                <path
+                  d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z"
+                  fill="#FFF"/>
+                <path
+                  d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z"
+                  fill="#FFF"/>
+              </svg>
+              RSS Feed Preview
+            </h1>
+            <h2><: $domain :></h2>
+            <p>
+              <xsl:value-of select="/atom:feed/atom:subtitle"/>
+            </p>
+            <a>
+              <xsl:attribute name="href">
+                <xsl:value-of select="/atom:feed/atom:link[2]/@href"/>
+              </xsl:attribute>
+              Visit Website &#x2192;
+            </a>
+
+            <h2>Recent blog posts</h2>
+            <xsl:for-each select="/atom:feed/atom:entry">
+              <div class="pb-7">
+                <div class="text-4 font-bold">
+                  <a>
+                    <xsl:attribute name="href">
+                      <xsl:value-of select="atom:link/@href"/>
+                    </xsl:attribute>
+                    <xsl:value-of select="atom:title"/>
+                  </a>
+                </div>
+
+                <div class="text-2 text-offset">
+                  Published on
+                  <xsl:value-of select="substring(atom:published, 0, 11)" />
+                </div>
+              </div>
+            </xsl:for-each>
+          </div>
+        </main>
+      </body>
+    </html>
+  </xsl:template>
+</xsl:stylesheet>