Make Videos Inconveniently Hard to Download

Table of Contents

Wait, why should it be hard? Well, because sometimes is just waaay too easy.

Intro

Let’s start with a simple case: I want to be able to download a video of a course I purchased on Udemy or Coursera. Or maybe I just want to download a video from YouTube and watch it again as many times as I want without having to watch the commercials. Unfortunately, this is not as simple as one might imagine.

When dealing with images in the browser, typically you can simply right-click and select “Save Image” to immediately have a copy available on your device. With videos, on the other hand, it is rare to be able to do this.

The reasons for these blocks can be different, but they all boil down to the following: the owner or distributor of the video wants to have control over its distribution. This also means that he or she may want to keep track of how many times it has been viewed, by whom, and restrict precisely its access to those without permission.

As a user I normally end up grumpy and give up trying to download the file. I know I could investigate and figure out how to download it permanently to my computer, but I soon realize that the time I would have to invest in figuring out the method is often not worth it.

But what if I were on the other side? What if I were to create content instead? If I wanted to make videos available, but didn’t want them to be freely accessible via a link to other users, how could I prevent that? Today I want to explore some of these options

Getting ready

We start by creating a simple web page that will contain a simple video-player. This will show a video that we will keep on our server (actually all local for now).

We start with the file index.html.

<html>
  <body>
    <video
      id="video"
      width="640"
      src="http://localhost:4444/video.mp4"
      controls
    ></video>
  </body>
</html>

and here a simple express server that we use to serve the video (saved in static/video.mp4)

const express = require('express');
const path = require('path');
const fs = require('fs');

const app = express();

// Static files from static folder
app.use(express.static('static'));

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
})

app.listen(4444, () => {
  console.log('Server is running on http://localhost:4444');
})

Once we run the server with node index.js we can access the HTML page and see that our video is visible

The problem with this solution is that you simply right-click to download the video in its entirety

Well, remove the click

Okay, so let’s try removing the option to have the right-click clickable, so you can’t even choose the option. To do this, simply edit the HTML code as follows

<html>
  <body>
    <video
      id="video"
      width="640"
      src="http://localhost:4444/video.mp4"
      oncontextmenu="return false;"  <!-- this is the line
      controls
    ></video>
  </body>
</html>

..but that doesn’t solve anything. In fact you only need to open the debugger to find the link to our video and download it directly from there

HTTP Live Streaming

This method causes our video to be “chopped up” and sent to the browser bit by bit, only when needed. This makes it difficult to get all the content immediately, and also improves the performance of our page (we do not download a single file, but many chunks).

Let’s see how to implement it

The encoding

First we need to chop up our file and get one with the format .m3u8. This file will be the index that will contain the list of all the pieces of the original video. The individual pieces are in .ts format (no, not TypeScript, it stands for transport stream)

For encoding, we can use ffmpeg in the following way.

ffmpeg -i video.mp4 -codec: copy -start_number 0 -hls_time 6 -hls_list_size 0 -f hls video.m3u8

This will split our file into snippets of 6 seconds each. Now we can use the hls.js frontend library to parse the content from the HSL to the screen, modifying the code as follows

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
  </head>

  <body>
    <video
      id="video"
      width="640"
      oncontextmenu="return false;"
      controls
    ></video>
  </body>
</html>

<script>
  var video = document.getElementById("video");
  var videoSrc = "http://localhost:4444/video.m3u8";
  if (Hls.isSupported()) {
    var hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  }
  else if (video.canPlayType("application/vnd.apple.mpegurl")) {
    video.src = videoSrc;
  }
</script>

We see this as transforming our requests

Our video now is broken up into several files, and it is certainly no longer straightforward to download the content. At this point it would be necessary to download all the .ts files, take the .m3u8 file, and use these to reconstruct the original .mp4 file. This definitely requires more effort than someone probably wants to invest in downloading a video. But it is also not impossible…

Signature to the rescue!

It would indeed be possible to create a separate HTML page, add the hls.js library, and do the parsing locally. At that point one could modify the code to save all the downloaded .ts files. We need to make this even more difficult

We can use CSRF tokens! Actually, we can use any type of token. The main concept is that the content must only be visible to the user who requested it and from the same device and browser from which they requested it.

If the client tries to download a .ts or .m3u8 file without having the correct code or token, it will be blocked immediately.

Now, let’s see how to add this server-side control. These are the necessary dependencies

const express = require("express");
const crypto = require("crypto");
const path = require("path");
const fs = require("fs");

const secretKey = "your_secret_key_here";

Below we define the functions to handle packet signature.

// Function to generate signature
function generateSignature(params) {
  const paramString = Object.keys(params)
    .sort()
    .map((key) => `${key}=${params[key]}`)
    .join("&");
  const hmac = crypto.createHmac("sha256", secretKey);
  hmac.update(paramString);
  return hmac.digest("hex");
}

// Function to verify signature
function verifySignature(params, signature) {
  const generatedSignature = generateSignature(params);
  return signature === generatedSignature;
}

// Middleware function to verify signature before serving HLS content
function verifySignatureMiddleware(req, res, next) {
  const { signature, ...params } = req.query;

  if (!signature || !verifySignature(params, signature)) {
    return res.status(403).send("Unauthorized");
  }

  // If signature is valid, proceed to serve HLS content
  next();
}

When we generate the signature we also want to use unique parameters for the user who requested it. This assumes that we also have an authentication system in place beforehand. For now, our friend John Doe will do. We have also created middleware that will take care of blocking all requests that do not contain the correct signature.

We now apply the middleware to all paths beginning with /hls, to apply it to all requests for video

// Route for serving HLS content, with middleware applied
// For every route that starts with /hls, the verifySignatureMiddleware will be applied
// This will ensure that the signature is verified before serving the content
app.use("/hls", verifySignatureMiddleware);
// Serve the HLS content
app.use("/hls", express.static(path.join(__dirname, "video")));

Now comes the interesting part. We need to serve the HTML page that contains the video player by injecting into it the signature that will be used for all subsequent requests. The important thing is that the signature is obtainable the first time only from the HTML page itself.

app.get("/", (req, res) => {
  // Take the user and timestamp as parameters, like the actual values
  const params = {
    id: "123",
    name: "John Doe",
    email: "john.doe@example.com",
  };
  const signature = generateSignature(params);
  res.setHeader("Content-Type", "text/html");
  // Load the content of index.html file
  const indexHtml = fs.readFileSync(
    path.join(__dirname, "static", "index.html"),
    "utf8"
  );
  // Replace the placeholder with the generated signature
  res.send(indexHtml.replace("{{signature}}", signature));
});

Frontend

Now let’s see what we find inside the HTML page

<html>
  <head>
    <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
  </head>

  <body>
    <video
      id="video"
      width="640"
      oncontextmenu="return false;"
      controls
    ></video>
  </body>
</html>

<script>
  const signature = "{{signature}}";
  const video = document.getElementById("video");
  const userInfo = {
    id: "123",
    name: "John Doe",
    email: "john.doe@example.com",
  };
  let videoSrc = "http://localhost:4444/hls/video.m3u8"
  const fragmentExtension = ".ts";
  let query = "signature=" + signature;
  for (const key in userInfo) {
    query += "&" + key + "=" + userInfo[key];
  }
  videoSrc = videoSrc + "?" + query;
  var originalOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function () {
    if (arguments[1].endsWith(fragmentExtension)) {
      arguments[1] = arguments[1] + "?" + query;
    }
    originalOpen.apply(this, arguments);
  };
  if (Hls.isSupported()) {
    const hls = new Hls();
    hls.loadSource(videoSrc);
    hls.attachMedia(video);
  } else if (video.canPlayType("application/vnd.apple.mpegurl")) {
    video.src = videoSrc;
  }
</script>

As you can see, the first line contains the signature token that we want to replace when we serve the page to the server-side user

const signature = "{{signature}}";

Then we edit all requests that contain .ts to have them include the signature as well. Let’s see the result

And network requests

If we tried now to open these without the correct signature, the server would respond with a 403 error

Outro

Of course, one can make life even more difficult for users: we can also create a different signature for each transport stream, so that it becomes even more difficult to script the download. In the end, the browser will always serve the content to the user, so somehow this content will be downloaded, but with the steps applied now it becomes almost more convenient to do screen recording while the content is playing.

The purpose is not to make downloading impossible, but to demotivate as much as possible anyone to do the downloading. What a time to be alive!


comments powered by Disqus