Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
1

Web Push Pitfall

from videah's blog [alt+shift+b] in technology

Whilst implementing Web Push notifications for my toy Q&A service curiouswolf I ran into an issue that led to me being stumped for hours. I was successfully receiving notifications from my server when using Chrome, but no matter what Safari was not playing ball. Web Push requires the browser maintainers to run a notification endpoint. For example, when you use Google Chrome and you enable notifications on a page, your encrypted notifications get funneled through a server Google controls. In the case of Safari however, their endpoint was returning BadJwtToken for me despite it being considered valid by Google. The Solution It turns out that Apple (and Mozilla apparently) require you to include a sub claim in your JWT with a method of contact (such as an email) for them to consider it valid. If you are providing an email, the sub claim should be prefixed with mailto: { "sub": "mailto:[email protected]", // <-- The important part "aud": "https://web.push.apple.com", "exp": "1680135079" } The reasoning behind this is to provide the operator of the endpoint with a means of contacting you if there's an issue. However, this technically goes against the Web Push spec, and it's frustrating that something like this isn't spelled out clearly in Apple or Mozilla's technical documentation. Notice the emphasis on the word MAY Apple quite regularly leave important information out of their docs for web APIs like this, but it's really messed up that the only place Mozilla talk about this is in a blog post from 2016.
30th Mar 2023

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from videah's blog

Plex Might Be Indavertently Bottlenecking You

Plex has this Relay feature that basically pipes your traffic through their servers if direct access isn't possible for whatever reason, bypassing NAT and presumably cutting down on their support tickets. For free users the relay is capped at a measly 1 Mbps and the [ever-growing expense that is the Plex Pass subscription service only bumps this up to a whopping 2 Mbps. I can imagine this being useful for a selection of people. But as this feature is turned on by default, it's very easy for any issues with your server's network configuration to go unnoticed. It turns out my port-forwarding rule for Plex hadn't been working for several months (thanks Ubiquiti) and I never noticed because the relay feature was masking the problem. So... you're telling me Plex isn't meant to be almost unusably slow this whole time...? It doesn't help that the remote-access configuration UI has been very buggy for 6+ years, making it impossible to tell if the issue is with your server or the broken interface. So please don't make the same mistakes as me. Make sure your Plex client is actually connecting to your server directly, and use a Port Forward Checker.

12th May 2025 1 votes
Credits

Credits / Attribution Some of the art used around this blog are by other people. Here is a list of them: Smug Videah by Kelevtov Rantsona Sidebar Videah by ScruffKerfluff Callout Pixel Art Videah by lockedsdcard Videah Mutant Standard Emoji by Caius Nocturne

21st Apr 2025 1 votes
My E-Reader Setup

I bought an e-reader recently, specifically the Kobo Libra 2. I felt like I wasn't reading books/manga as much as I wanted and I think the convenience of being able to carry my entire library around with me will help with that. I own an iPad Pro but I wanted a device I didn't need to think about charging that often and wouldn't give me eye strain. This post is a bundle of notes about my current reading setup. I had owned a Kindle Paperwhite before but it was so locked down that I couldn't find any joy out of using it since I was forced to use it how Amazon product managers wanted me to. The Libra 2 I spent several hours doing research and I think the Libra 2 is the best device on the market at the moment, at least for my needs. I picked it up in white to try and make an important psychological distiction. I wanted it to differ from every other electronic rectangle I own and treat it more like a nice book rather than a computer that will inevitably make me angry. Which all things considered is quite funny with all the computer nonsense I ended up doing to it. It's got a 7-inch screen which is a little shy of the perfect Tankōbon volume size, but close enough that manga is comfortable to read. I remember reading somewhere that the screen has one of the best contrast ratios in an e-reader and it is indeed very good. Navigation buttons are a dying breed in the e-reader space but I really can't live without them. Using a touchscreen to navigate is hateful and is really fake skeumorphism. You can pretend all you want but it does not feel like you are flipping a page! Thankfully the Libra 2 has two buttons on the side where you hold it so you can go back and forth with just one hand. I got the very overpriced official SleepCover case in orange. Matches my iPad case. Stops the plastic from making flexing sounds. It can let your e-reader stand upright for some reason. Book Management The only real book management software in town for e-readers is Calibre but it's stuck in the late 2000's UNIX school of UI design and I reaaally don't want to deal with that kind of thing anymore. I want organising my books to feel comfy. I also don't want it to depend on having my laptop with me, constantly wiring it to my e-reader to sync new books. It's not perfect but Calibre-Web works better for me. Despite the confusing name it's a completely distinct project that just happens to be compatible with Calibre's database format. I have it hosted on my personal VPS so I can access it anywhere and can give friends access if I need to. It's not quite Plex for books but it is close. The bootstrap UI is a little dated but it works for what I need. I'm keeping my eye on a relatively new project called Librum that's in its infancy. If it matures it promises some nice features like syncing reading positions and its own dedicated client. Reader Software The default reader software the Libra 2 comes with is called Nickel and it's fine. It looks and runs ok enough and the built in Kobo Sync functionality can be tweaked to work with my Calibre-Web instance by replacing the endpoint in a config file, which I have to give Rakuten props for letting you do. But sideloaded files can be very broken. I was half way through a book before I realised Nickel was just throwing all of my notes and highlights into the void whenever I closed it, which was very rude. This is apparently a common problem that's been around for a while and it seems Rakuten have no plan to fix it anytime soon. I went looking around for some open source alternatives. Most people reach for KOReader. It runs on pretty much everything and is packed with an ungodly amount of features and has a steady community built around it. But if I'm being honest I just can't find any enjoyment using it. The user interface just isn't up to snuff. It feels impossible to navigate and there is no real library view, only a file manager. When I'm holding my e-reader I don't want to be reminded that it is a computer all the time. It's meant to be a book! I came across Plato which is made exclusively for Kobo devices and worked on by the guy responsible for bspwm. It's really fast and responsive, more so than Nickel even. The UI is pretty much exactly what I'm looking for. It's not nearly as feature complete as KOReader but it's written in Rust which is a language I feel confident enough in to tweak and implement things to my hearts desire. I've already made a small change that got merged upstream too! Syncing Originally I was using Kobo Sync with the endpoint changed to point to my Calibre-Web instance to sync my books wirelessly. But it was buggy and slow and could only pull in KEPUB files. Plato has a built in hook system that lets you run arbitrary binaries when a directory is opened. Using this I wrote a small CLI tool that just pulls every book that hasn't been downloaded yet from my Calibre-Web instance using the OPDS protocol. I've stuck the code for this tool up on my Github if anyone else is interested in using it! In the future I would like to take advantage of this hook system to also sync reading positions and other metadata Plato stores. Tailscale VPN I love plugging as many of my devices into my Tailscale network as I can and I thought it'd be a fun little project to try and get it running on the Libra 2. Someone had already done most of the work by getting it to run on the Kobo Sage. It turns out it's a little harder for the Libra, I had to spend a ridiculous amount of time compiling some missing kernel modules in a horrendously slow emulated x86 emulator just to get this thing running. The joys of owning an ARM laptop. I think being able to type ping kobo and have it Just Work makes all the hours spent worthwhile though ☺️ I packaged all the work I done up nicely in an install script so it should be dead easy to get this working on your Libra 2 as well. Tailscale has a built in AirDrop-esque file transfer feature that I find really useful for quickly sending PDFs to my e-reader that I don't necessarily want to be a permanent part of my library. Unfortunately I couldn't get Tailscale SSH working which was one of the main reasons I attempted this in the first place 😢 I'm not sure why but my first guess would be that I need iptable packet filtering but that would require swapping out the whole kernel which I don't feel comfortable doing at the moment. Maybe some day though. SSH The Libra 2 only has telnet support out of the box behind a developer cheat code (typing devmodeon into the search bar) which is fine because I'd be using it over a Tailscale wireguard connection anyway. My threat model doesn't exactly involve people bruteforcing telnet over public WiFi just to steal my furry books. But for completions sake I stuck the Dropbear SSH server on mine and then disabled telnet. Storage The Libra 2 has 32gb of internal storage which is pretty huge for books considering how small EPUBs usually are. But I like reading manga which can be pretty hefty. An average volume is 200mb, with stuff like Uzumaki coming in at 800mb. What if I need to store every uncompressed volume of One Piece for offline reading in the event of the apocalypse? Most e-readers treat manga as secondary to books and rarely offer higher storage options or mini-SD card slots anymore. Thankfully the Libra 2 has a mini-SD slot... it's just an internal one not meant to be user accessable 🫠 Image taken by MobileRead user 'supermighty' This is better than not being able to upgrade the storage at all though and was actually one of the main things that pushed me in the Libra 2's direction when doing research. Someone on the MobileRead forums replaced the SD card and posted instructions on how to do so. I haven't done this for mine yet but I plan on giving it a shot and sticking something utterly ridiculous like 512gb or 1tb in there. It seems like a pretty risky/messy mod to do though, the slot and card are caked in waterproofing glue that you'll need to reapply afterwards if you're like me and enjoy reading in the bath. Even if this seems excessive it does let me sleep easier at night knowing I can replace the internal storage in the event it somehow fries itself. Dictionary This is a feature I didn't know I wanted till I used it. It's a pretty big leg-up over reading physical books when I can easily hold down on a word I'm not sure about. I'm using the ebook-reader-dict project which is based on Wiktionary since that's the one Plato recommends and is designed to work with. Unfortunately it's a bit hit or miss. I'm not too happy about the quality of the definitions sometimes, I'll probably look at alternatives at some point. Plato renders the definitions without any padding which looks a bit weird. I can probably fix this easily in the code later though. Feels good using open source software sometimes. Font For reading, I use the font Atkinson Hyperlegible which you might know from Cohost, but it's also used by the very blog you're looking at now! I've fallen in love with it as it's really easy to read even when the text is very small. Very pretty and accessible.

10th Aug 2023 1 votes
Howdy, Is This Thing On?

Since things are uh... collapsing? I thought it'd be a good idea to finally get my blog up and running. Come in, get comfy and take a look around! Cozy right? Getting Started (Again) Ok admittedly, this is my 4th attempt at making a blog. This time I'm using Zola which is like Hugo or Jekyll but more Rust-y and less JavaScript-y. I can't make any comparisons beyond that because I haven't used either, but my initial impressions of Zola are good! The theme you're looking at I made from scratch using Tailwind which is the only way I can do frontend web stuff now. I wanted to move away from what I had been using in the past (Ghost) and have my blog sit in a git repo in plain text instead. How I currently have things set up means when I push any changes to the blog's GitHub repo an action runner will automatically build my site (in about 17ms!), compile Caddy, embed the site into the binary using caddy-embed (in... a lot more than 17ms!), then finally push it as an image to DockerHub. My server will then see this new image, hot-swap it, and start serving my blog straight from memory! Little Gremlin I hope you like my little rantsona in the sidebar! He took the longest to get working out of everything if you can believe it. I can disable him on a per-article basis, you don't have to worry about him 🤔-ing the more serious posts. You can't see him unfortunately (try looking at this page on a larger screen) but I hope you like my little rantsona in the sidebar! He took the longest to get working out of everything if you can believe it. I can disable him on a per-article basis, you don't have to worry about him 🤔-ing the more serious posts.

14th Nov 2022 1 votes

More in technology

Is This A Joke? In The Auth Header? (F5 BIG-IP UnAuth Heap-Overflow to RCE CVE-2026-94127)

Well, well, well, well, well, well, well, well, well, well, well, well, well, well, well. We're back. Sorry. We've been watching the onslaught of vulnerabilities flood the internet. Every man, dog, and their grandmas (apparently?) are now using LLMs to find and reproduce vulnerabilities - it’

4 hours ago 1 votes
The Reason You Prohibit Things

You want less of them. That’s the reason. You may find that it’s too hard to stop people from doing the thing, literally blood, sweat, and tears trying to prosecute people, but that’s a different thing.

6 hours ago
Solitaire Alone Together

Solitaire Alone Together I made a new game. It's called Solitaire Alone Together. It's Windows 98 solitaire, but you can play with everyone else on the internet. Read the full post on my blog! Here's a raw link, if you need it: https://eieio.games/blog/solitaire-alone-together

3 days ago 1 votes
All The Ways I Broke My Website

This post is a living diary of all the times I messed up something with my website in a funny way. I value those who have the confidence to own their mistakes and share the learning with others, and so this is me doing just that! That Time I Accidentally Made a Tarpit That Time I Accidentally Made Really Large Headers That Time I Accidentally Made a Tarpit Back to Top A "tarpit" is an unofficial term used in computing to describe an intentionally slow response to a request. In these modern times many people are using tarpits as a way to combat the relentless theft of data by AI companies, although there's little to no evidence of that actually being in any way effective. I don't use tarpits, at least not intentionally, but there was that one time when I accidentally created a tarpit and trapped all visitors in it. As I've shared previously, I refuse connections from IP addresses that are blocked or belong to a blocked subnet, and I enforce this firewall during the TCP handshake. The logic here is straightforward: there's no reason to waste resources doing a TLS handshake, accepting an HTTP request, and then rejecting the connection if I already know I'm going to reject it at the earliest step. At the time, the code worked like this: the HTTP server would repeatedly call the Accept() function below expecting a new connection. I've added some comments to help explain the logic. func (l *firewallListener) Accept() (net.Conn, error) { // Accept the connection from the TCP listener. This blocks until there is a connection to accept or the listner was closed. conn, err := l.l.AcceptTCP() if err != nil { return conn, err } // Separate the IP address out from the remote address (which includes the port) ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } // Check if it's blocked, if so close the connection and return a refuseError if IsBlocked(ip, true) { conn.Close() return nil, &refuseError{} } // Otherwise return the connection on to the HTTP server return conn, nil } If the incoming connection was from a blocked IP then I'd return a refuseError. I need to use a specific error interface because the HTTP server will halt if it encounters a non-temporary error from the call to Accept(), so I need to return an error that satisfies the definition of a temporary error. I defined refuseError like this: type refuseError struct{} func (e *refuseError) Error() string { return "." } func (e *refuseError) Timeout() bool { return true } func (e *refuseError) Temporary() bool { return true } func (e *refuseError) Is(err error) bool { return err == context.DeadlineExceeded } This did accomplish the goal of rejecting connections before the TLS handshake for blocked addresses, but it had one really unintended and difficult to track down side-effect. Accepting connections is done serially, after which servers typically then process that request on a dedicated thread (or in Go's case a goroutine). This means that any delays during the accept loop will block all incoming connection. What I had missed while reviewing the code for Go's HTTP server is that when it receives a temporary error from Accept() is that while it doesn't abort, it does sleep for up to a maximum of 1 second. This sleep blocks the entire server for all incoming connections. You can see a trimmed copy of the code that does this below, with some marks I've added which I will explain. // src/net/http/server.go // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. for { // (1) rw, err := l.Accept() if err != nil { if s.shuttingDown() { return ErrServerClosed } // (2) if ne, ok := err.(net.Error); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } s.logf("http: Accept error: %v; retrying in %v", err, tempDelay) // (3) time.Sleep(tempDelay) continue } return err } connCtx := ctx if cc := s.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic("ConnContext returned nil") } } tempDelay = 0 c := s.newConn(rw) c.setState(c.rwc, StateNew, runHooks) // before Serve can return // (4) go c.serve(connCtx) } At mark 1 the server calls the Accept() function, this is the exact function that I defined above where I might return a temporary error. At mark 2 it checks if an error was returned, and if so if that error is temporary. If there was a temporary error, at mark 3 it sleeps for an increasing amount of time up-to 1 second, otherwise, at mark 4 it processes the connection on a dedicated goroutine, which allows the server to accept the next connection. I'm not entirely sure why the Go developers added this sleep delay and the change when it was introduced doesn't provide any meaningful insight. Regardless, it caused significant latency connecting to my website when a flood of rejected requests was coming in. It just goes to show how important it is to write meaningful commit messages, because you never know when somebody might come back years later wondering "why was this done?". I sure home I don't come to eat those words later. Coincidentally, you can actually see this happening if you look carefully at one of the metric graphs I shared in my first post about my server's security model: Securing My Web Infrastructure. This is the graph I shared in that blog post and while I didn't know it at the time, the fact that these request spikes all cap-out at around 60 requests per minute was not a coincidence. These requests were not being made with a limit in mind, attackers rarely ever care about things like that, instead it the accidental tarpit I had created. The downside to this was that while the malicious requests were being rate-limited, all requests were being rate-limited, up to a point of taking so long they timed out. The Fix Fixing the issue was relatively straightforward enough. Instead of returning a temporary error to the HTTP server during the accept loop, just don't return anything at all and wait for the next valid connection. func (l *firewallListener) Accept() (net.Conn, error) { for { conn, err := l.l.AcceptTCP() if err != nil { return conn, err } ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } if IsBlocked(ip, true) { conn.SetLinger(0) conn.Close() continue } return conn, nil } } Now, when the HTTP server calls Accept(), the only time it returns is with a connection from an IP that isn't blocked, or if there genuinely is an error. No more sleep delays, no more excessive timeouts. That Time I Accidentally Made Really Large Headers Back to Top For about 10 years now all major browsers have support for a security feature known as a Content Security Policy or CSP. A CSP is an HTTP header provided by the server that instructs the browser on where it can load assets from, this could be scripts, images, stylesheets, fonts, etc. The objective of using a CSP is to prevent against injected HTML that tries to load assets, such as a malicious Javascript file, from a remote source. With so much user-provided content being available online, it's very possible for this to happen without an attacker compromising the entire web server. CSP protects against that by saying "scripts can only be loaded from these domains". That's a really simplified way of looking at it, anyways. My web server supports injecting the CSP header automatically, but before I go on I need to explain a little bit about the structure of my web server. When an incoming HTTP request is accepted (having passed all firewall checks and assertions), we look at the destination host for the request. This can either be the value of the Host header or as specified during the TLS handshake. We then look at a map of hosts to apps. Apps are just an interface that accept a few methods: type App interface { Cleanup() ReloadConfig() ServeHTTP(rw http.ResponseWriter, r *http.Request) Setup(dataDir string) error Shutdown() } One of the apps is the Proxy app, which is a reverse proxy - it accepts the incoming HTTP request and then proxies it on to another host. This is a very common design, especially with increasingly complex TLS setups. Because each app is unique to a host, and different hosts have different requirements for CSP rules, the proxy app includes a CSP preset that we use to build the header value, or skip it entirely. When the proxy app was going to copy an HTTP request to the downstream host, it would build the CSP header, however there was a slight bug... func (a *App) ServeHTTP(rw http.ResponseWriter, inRequest *ht2.Request) { // --snip -- if a.CSP != nil { a.CSP.ConnectSrc += " " + inRequest.Origin } CopyHttpRequest(inRequest, outRequest, rw, CopyHttpRequestOptions{ Origin: inRequest.Origin, Csp: a.CSP, Cors: a.CORS, AddHeaders: !a.SkipHeaders, UseHTTP3: a.UseHTTP3, InsecureTLS: a.InsecureTLS, }) } I'm really unsure as to what I was doing with the line to append to the ConnectSrc, but the impact is that I'm appending to a variable that lives on the App, rather than a variable that is per-request. This meant that every time there was a request to the app, any request at all, the origin would be appended to the header value. This went on for quite a long time unnoticed and unresolved, largely because I am constantly tweaking and tinkering with my web server, after all, it's how I made having a website fun again. Each time I restarted the server process, the header value would be reset, but only for it to continue to grow and grow. Eventually, after a period of being busy with other matters, the server process stayed running for long enough that the header value grew too large and HTTP clients began to reject it. There is no defined maximum for an HTTP header value, however most HTTP clients use 100KiB, which is perfectly reasonable, and this header value would continue to grow well beyond that. Diagnosing this issue turned out to be difficult as tools like Curl would fail with errors relating to entities being too large, but stopped short of saying what specifically. I eventually used openssl s_client to send an HTTP request by hand and observed my terminal window being filled with a domain name repeated thousands of times. Looking at the commit history, it was really unclear why I added the culprit lines of code. The commit message just says "Improved CSP support". It just goes to show how important it is to write - hey look it's those words I'm now having to eat! The Fix The fix was to just delete those three lines of code. Yup, it really was that simple, and fixing this bug actually made a larger positive impact than I had expected, as it was immediately clear when I fixed the bug by looking at outbound network bytes: So much traffic was being wasted on excessive header sizes. You might look at these mistakes I've made and think "wow, Ian, these are some obvious mistakes, I never would have made them!" to which I say "good for you!" with the utmost sarcasm and disdain. I enjoy making and refining software, and making anything means making mistakes along the way. Each time I make mistakes such as the ones above, I improve my skills of investigation, diagnosing, and repair. Skills that, judging by my peers in the industry, seemingly everyone is quickly willing to throw away because a robot does it "better" than you. Header Image: "Car accident on the Ffestiniog to Bala road. Nobody was hurt" by Geoff Charles, CC BY-SA 4.0, via Wikimedia Commons.

5 days ago
Buienwatch, a custom Home Assistant integration for Buienradar and Buienalarm graphs on your Apple Watch

Back in 2021, I wired up data from Buienalarm and Buienradar through Node-RED and a big pile of Jinja2 templates to get a rain forecast graph on my Apple Watch: eight Unicode block characters showing the next two hours in 15-minute chunks, glanceable without unlocking the phone. It worked, and I used it every day […] The post Buienwatch, a custom Home Assistant integration for Buienradar and Buienalarm graphs on your Apple Watch appeared first on Style over Substance.

a week ago 0 votes
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in