Search code examples
fastifyhelmet.jscontent-security-policy

Content Security Policy for YouTube under Fastify


I find myself facing a CSP authorization problem for YouTube:

Refused to connect to 'https://youtube.com/oembed?url=http://youtube.com/watch?v=3Bs4LOtIuxg' because it violates the following Content Security Policy directive: "default-src 'self' 'unsafe-inline'". Note that 'connect-src' was not explicitly set, so 'default-src' is used as a fallback.

My stack is based on Fastify and I use Helmet to manage CSP authorizations , more precisely an encapsulated version for Fastify: @fastify/helmet. Basically under the hood it's the same thing, however I'll clarify just in case...

Here is my configuration under Fastify:

import helmet from '@fastify/helmet'

app.register(helmet, {
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self' 'unsafe-inline'"],
      imgSrc: [
        "'self' data: *.openstreetmap.org",
        "'self' data: *.openstreetmap.fr",
        "'self' data: *.ign.fr",
        "'self' data: *.youtube.com",
        "'self' data: *.ytimg.com", // YouTube, certaines miniatures du player et notamment l'image par défaut.
      ],
      frameSrc: ["'self' data: *.youtube.com"],
    },
  },
})

It is certain that the problem does not come from elsewhere than this code (eg: server configuration) because when I delete the Helmet code here I regain use of my YouTube players. Conversely, permissions for OpenStreetMap and IGN are no problem.

What drives me crazy is that when I was prototyping in Express.js I had no problem playing my YouTube videos:

const helmet = require('helmet')

app.use(
  helmet.contentSecurityPolicy({
    directives: {
      defaultSrc: ["'self' 'unsafe-inline'"],
      imgSrc: [
        "'self' data: *.openstreetmap.org",
        "'self' data: *.openstreetmap.fr",
        "'self' data: *.ign.fr",
        "'self' data: *.youtube.com",
        "'self' data: *.ytimg.com", // YouTube, certaines miniatures du player et notamment l'image par défaut.
      ],
      frameSrc: ["'self' data: *.youtube.com"],
    },
  })
)

I should point out that the two projects, Fastify and Express, are compared in localhost and that they both use the same version of Node.js (v21.6.1).

If anyone of you has an idea, unless the problem is right there in front of me... in any case I'm interested.

EDIT n°1:

pn ls for Fastify:

dependencies:
@fastify/cookie 9.3.1        @fastify/helmet 11.1.1       @fastify/static 6.12.0       argon2 0.31.2                ip 1.1.8                     
@fastify/flash 5.1.0         @fastify/postgres 5.2.2      @fastify/view 8.2.0          fastify 4.25.2               pg 8.11.3                    
@fastify/formbody 7.4.0      @fastify/session 10.7.0      @js-temporal/polyfill 0.4.4  fastify-plugin 4.5.1         pug 3.0.2                    

devDependencies:
postcss 8.4.33
postcss-advanced-variables 3.0.1
postcss-calc 9.0.1
postcss-cli 11.0.0
postcss-import 16.0.0
postcss-minify 1.1.0
postcss-preset-env 9.3.0
svg-symbol-sprite 1.4.1
svgo 3.2.0
terser 5.26.0

pn ls for Express:

dependencies:
argon2 0.28.2             date-easter 0.2.5         express-session 1.17.1    http-errors 1.8.0         morgan 1.10.0             pug 3.0.2                 url 0.11.0                
compression 1.7.4         debug 4.2.0               express-urlrewrite 1.4.0  i18n-iso-countries 6.0.0  passport 0.4.1            serve-favicon 2.5.0       
connect-flash 0.1.1       dotenv 16.0.1             express-useragent 1.0.13  ip 1.1.5                  passport-local 1.0.0      suncalc 1.8.0             
cookie-parser 1.4.4       express 4.18.2            helmet 4.5.0              luxon 2.0.2               pg-promise 10.11.0        uglify-es 3.3.9           

devDependencies:
autoprefixer-stylus 1.0.0
browser-sync 2.26.14
eslint 7.25.0
jest 27.2.0
nodemon 2.0.22
postcss 8.2.15
stylus 0.59.0

EDIT n°2:

I just did a curl -I <url> on each of my two projects. Although the Fastify plugin relies on Helmet, it does not return the same CSP rules as for Express.

Fastify:

HTTP/1.1 200 OK
Content-Security-Policy: default-src 'self' 'unsafe-inline';img-src 'self' data: *.openstreetmap.org 'self' data: *.openstreetmap.fr 'self' data: *.ign.fr 'self' data: *.youtube.com 'self' data: *.ytimg.com;frame-src 'self' data: *.youtube.com
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Origin-Agent-Cluster: ?1
Referrer-Policy: no-referrer
Strict-Transport-Security: max-age=15552000; includeSubDomains
X-Content-Type-Options: nosniff
X-DNS-Prefetch-Control: off
X-Download-Options: noopen
X-Frame-Options: SAMEORIGIN
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 0
// ...

Express:

HTTP/1.1 200 OK
x-powered-by: Express
content-security-policy: default-src 'self' 'unsafe-inline';img-src 'self' data: *.openstreetmap.org 'self' data: *.openstreetmap.fr 'self' data: *.ign.fr 'self' data: *.youtube.com 'self' data: *.ytimg.com;frame-src 'self' data: *.youtube.com
// ...

I'm going to try to proceed by elimination, no longer using Helmet, but by doing something like this:

app.addHook('onRequest', (req, res, done) => {
  // prettier-ignore
  res.headers({
    'Content-Security-Policy': "default-src 'self';base-uri 'self';font-src 'self' https: data:;form-action 'self';frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests",
    'Cross-Origin-Opener-Policy': 'same-origin',
    'Cross-Origin-Resource-Policy': 'same-origin',
    'Origin-Agent-Cluster': '?1',
    'Referrer-Policy': 'no-referrer',
    'Strict-Transport-Security': 'max-age=15552000; includeSubDomains',
    'X-Content-Type-Options': 'nosniff',
    'X-DNS-Prefetch-Control': 'off',
    'X-Download-Options': 'noopen',
    'X-Frame-Options': 'SAMEORIGIN',
    'X-Permitted-Cross-Domain-Policies': 'none',
    'X-XSS-Protection': '0',
  }) // Set referrer policy to same-origin
  done()
})

Edit n°3:

Well, already, there were repetitions in my code, at least this problem will have allowed me to understand what I am configuring:

app.register(helmet, {
  contentSecurityPolicy: {
    //useDefaults: false,
    directives: {
      defaultSrc: ["'self' https:"],
      imgSrc: [
        "'self'",
        'data:',
        '*.openstreetmap.org',
        '*.openstreetmap.fr',
        '*.ign.fr',
        '*.youtube.com',
        '*.ytimg.com', // YouTube, certaines miniatures du player et notamment l'image par défaut.
      ],
      frameSrc: ["*.youtube.com"],
    },
  },
  //crossOriginEmbedderPolicy: false,
  //crossOriginOpenerPolicy: true
})

I managed to get YouTube videos to work, but I had to add https: to defaut-src, which doesn't seem great to me, and above all I don't understand why it works on Express without this parameter.

The question therefore remains open...


Solution

  • I give up knowing why with the same Helmet config I did not have the same result on Express as on Fastify. This was a bad starting point for debugging and I wasted a lot of time with it. But in the end it wasn't very important and it was better to concentrate on CSP logic, it's much more educational and profitable.

    So, removal of Helmet.js: less "magic" and more understanding of the fundamental code for the amateur developer that I am. And what's more, it's 5-10% faster than Helmet according to the creator of the plugin, that's still a good thing.

    In the end I wrote my own replacement solution for Helmet.js and in doing so, not only did I familiarize myself with the CSP rules (which I had never taken the time to know), but I wrote from scratch my first non-encapsulated Fastify plugins:

    import fastifyPlugin from 'fastify-plugin'
    
    /**
     * Configuring HTTP headers
     * Use of a Hook to configure the header to the detriment of the turnkey solution `@fastify/helmet`, this in order to avoid a "black box" solution which will work in a "magical" way for the developer.
     * @see https://helmetjs.github.io/faq/you-might-not-need-helmet/
     * @param {FastifyInstance} app
     */
    async function HTTPHeaders(app) {
      await app.addHook('onRequest', (req, res, done) => {
        res.headers({
          'Content-Security-Policy': [
            "default-src 'self' 'unsafe-inline' data: https:",
            "base-uri 'self'",
            "font-src 'self'",
            "form-action 'self'",
            "frame-src 'self' *.youtube.com",
            "media-src 'self'",
            "img-src 'self' data: https:",
            "object-src 'none'",
            "script-src 'self'",
            "script-src-attr 'none'",
            "style-src 'self' 'unsafe-inline'",
            "manifest-src 'self'",
            'upgrade-insecure-requests',
          ],
          'Cross-Origin-Opener-Policy': 'same-origin',
          'Cross-Origin-Resource-Policy': 'same-origin',
          'Origin-Agent-Cluster': '?1',
          'Referrer-Policy': 'no-referrer',
          'Strict-Transport-Security': 'max-age=15552000; includeSubDomains',
          'X-Content-Type-Options': 'nosniff',
          'X-DNS-Prefetch-Control': 'off',
          'X-Download-Options': 'noopen',
          'X-Frame-Options': 'SAMEORIGIN',
          'X-Permitted-Cross-Domain-Policies': 'none',
          'X-XSS-Protection': '0',
        }) // Set referrer policy to same-origin
        done()
      })
    }
    
    export default fastifyPlugin(HTTPHeaders)
    

    The CSP variables passed in an array are completely valid, this can be checked with a curl -I <URL to check> command.

    We learn something every day... Subject resolved.