Why Referer Breaks Chromecast

Hotlink protection is both a gift and a curseOctober 19, 2021

TV in a darkened room playing static Photo by Ron Lach from Pexels

A Chromecast is a little device you plug into your TV's HDMI port, and use to play media with from a supporting application on another device. Under the hood, it's essentially a Chromium browser on dedicated hardware. It can play anything a regular browser would, and would have the same issues a regular browser would, as I would find out next.

One day, my Chromecast started having issues streaming one video source I've been using for a while. I would press the cast button from my device, select my TV Chromecast as the destination, the Chromecast app would launch on the TV and... nothing. App would just exit back to the home screen and everything would be back to the beginning. For a while, I just dismissed this as a bug somewhere. But the web developer in me whispered otherwise.

So I decided to investigate.

Prepwork

Before I started debugging, I did some prep work. I took steps to eliminate early and obvious variables, and give the Chromecast the best chance at playing the content. This included making sure the connection to the local network and the internet was good, ensuring nothing was blocking it from the video source, making sure software of the involved devices were up to date. That kind of stuff.

I also forgot to mention one crucial detail. Chromecast comes in a variety of forms. It could be the dedicated stick/puck variant, the Android TV dongle variant, it could be included in a smart TV's operating system, and so on. The one that I have is the Chromecast in an Nvidia Shield TV. It runs Android TV which means it's debuggable as an Android device. So I picked up adbLink, which is a simple GUI for Android's adb, and started reading the logs.

Debugging android

Since Chromecast is really just Chromium under the hood, I started sifting through the logs looking for hints of Chromium. This means looking for things that sound like "Cast", "Chromecast", "CR", "Chrome", "Chromium", or anything that sounds browser-like in nature. After a while, I found the relevant log lines that involve the casting operation (log lines cleaned up for brevity).

I chromium: App started: CC1AD845 (Default Media Receiver)
I chromium: Heartbeat for V2TransportImpl started: max_inactivity=10000000, heartbeat_interval_=5000000
I chromium: Cast v2 action(socket_id=local-client.53): CastV2.Connection.Connect.In
I chromium: isTypeSupported("video/mp4;codecs=hev1.2.4.L153.B0") -> true
W chromium: Unknown player state:
I chromium: Focusing foreground application
I chromium: Initialized cast web view with hidden visibility due to media block
I chromium: isTypeSupported("video/mp4;codecs=dvhe.04.06") -> false (not supported by HTMLMediaElement)
D cr_CastContentWindow: createWindowForWebContents
I chromium: Updating app visibility to visible. State: 0
I chromium: App running: CC1AD845 (Default Media Receiver)
D cr_CastContentWindow: grantScreenAccess
D cr_CastWebComponent: Starting WebContents with delegate: ActivityDelegate; App ID: CC1AD845; Visibility Priority: 5
D cr_CastWebContent_AD: start: SHOW_WEB_CONTENT in activity
D cr_CastWebComponent: start activity by intent: Intent { act=android.intent.action.VIEW flg=0x30014000 cmp=com.google.android.apps.mediashell/org.chromium.chromecast.shell.CastWebContentsActivity (has extras) }
I chromium: isTypeSupported("audio/mp4;codecs=ec-3") -> false (not supported by HTMLMediaElement)
I MediaFocusControl: requestAudioFocus() from uid/pid 10035/9129 clientId=android.media.AudioManager@31dd6fdorg.chromium.chromecast.shell.CastAudioManager$$Lambda$1@7b5221b callingPack=com.google.android.apps.mediashell req=1 flags=0x0 sdk=30
I ActivityManager: START u0 {act=android.intent.action.VIEW flg=0x30014000 cmp=com.google.android.apps.mediashell/org.chromium.chromecast.shell.CastWebContentsActivity (has extras)} from uid 10035
I WindowManager: WindowState() - found Launcher @ mAppToken:AppWindowToken{5d5b61c token=Token{a30338f ActivityRecord{52742ee u0 com.google.android.apps.mediashell/org.chromium.chromecast.shell.CastWebContentsActivity t2453}}}
I chromium: Screen access granted
D cr_CastContentWindow: grantScreenAccess
I chromium: Initialized cast web view with hidden visibility due to media block
D cr_CastContentWindow: requestVisibilityPriority visibility=5
D cr_CastWebComponent: Visibility:5
I chromium: Updating app visibility to visible. State: 0
I chromium: Application manager triggering HDMI CEC OneTouchPlay.
I chromium: Cast v2 action(socket_id=local-client.54): CastV2.Connection.Connect.In
I chromium: Current app changed to CC1AD845

As you can see, the log lines point out a sequence of operations that pertain to the launch of the Chromecast app, and the attempt to play media. Unfortunately, further down the logs, I get a generic error before it exits the app:

E chromium: Resource "https://cdn.domain1.com/path/to/playlist.m3u8" failed to load with net_error=-2, description=ERR_FAILED

The error doesn't sound like much, it's a catch-all error type according to the Chromium source and it could be caused by anything. But the .m3u8 caught my eye, because that's the file type used for HLS streaming. Remember how you can switch between video qualities on your favorite streaming platform? You're actually switching between .m3u8 sources. Specifically, you're switching playlists, which each playlist being a specific quality, and each buffered segment of the media as an item in that playlist. And I know one other app that can consume .m3u8 playlists: VLC.

Debugging VLC

Hopping over to VLC, opened the .m3u8 as a network stream, hit play, and...

Your input can't be opened:
VLC is unable to open the MRL. Check the log for details.

Ok, off to a bad start. While telling me to look at the logs, logging on VLC is actually disabled by default. So after a trip to the preferences pane, I enabled logging and tried once more. This time, looking at the logs, I get a very short yet very informative error:

access error: HTTP 403 error

An HTTP 403 tells us a lot of things. An HTTP 4xx error is a client-side error, which means there's something about the HTTP request that's wrong. This also means the server is working, and that we are authenticated but not authorized to access the resource. Given that a normal desktop browser can consume the same file without issue, and that a Chromecast is just a browser under the hood that's supposed to be doing the same thing, there's something about the HTTP request Chromecast generates that gets us this 403.

Debugging on the browser

So going back to the browser, playing the video, and inspecting the network logs, the request for the .m3u8 file looks like this:

GET /path/to/playlist.m3u8 HTTP/2
Host: cdn3.domain1.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:93.0) Gecko/20100101 Firefox/93.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://domain2.com/
Origin: https://domain2.com
Connection: keep-alive

The request is just a plain GET request to the .m3u8 file. It had no query parameters nor cookies involved, which rules out prerequisite requests and custom logic. However, there is one thing that is suspect: the Referer header. Referer checks are often used by servers to ensure that requests are only served when they come from allowed sources. In the context of media, it's used as a form of hotlink protection. Server operators do this to avoid overloading their servers serving media on webpages in websites other than their own. In this case, the Chromecast is essentially another webpage on another website trying to load a hotlinked media source.

Testing the theory

With all that information, I formulated a simple test procedure. I copied the HTTP request from the browser onto an HTTP client and tried to reproduce a normal browser request. This way, I would have a baseline to compare with. It also ensures that the request goes through even if we're not using a browser. Firing it up, the HTTP client returned the same response as the browser, the contents of the .m3u8 file.

Now for the moment of truth. I tested both altering and removing the Referer header to see what it would return. And wouldn't you know, it returned an HTTP 403. So hotlink protection it is! I declare the case closed, now I can go to sleep. It's not like it's 2:30 in the morning on a work day.

Conclusion

I get why video operators and website owners implement hotlink protection. They want to prevent server abuse and also ensure that the media is consumed from their web properties. But it's just amazing that hotlink protection can break casting functionality. It's probably the cause of most of the cases where I can't cast some random web video onto the Chromecast.

Also, in hindsight, it would have been relatively easier to stick a proxy in front of the Chromecast. That way, I could have sniffed out that HTTP 403 and skipped the entire process of reading the logs and bringing in VLC to debug that ERR_FAILED. But then, it would only have happened if I knew ahead of time that it was a network issue. Also, the logs provide a lot more context and depth, especially if that error was not network related.

These are the kind of problems I love to work with, problems where the answer is unknown, where I have to seek out knowledge, try and fail multiple times, simmer in the theory, sleep over them if needed, and acquire tools and practices along the way. I learned a lot from this exercise. It also refreshed my knowledge regarding HTTP debugging, practices, and tooling. The last thing I want to happen is to become rusty at the trade I know and love.

Stay safe, and stay curious. 🤓