DEV Community

Cover image for The Safest Login Page is the One I Never Published
Wilhelm Murdoch
Wilhelm Murdoch

Posted on Originally published at wilhelm.codes on

The Safest Login Page is the One I Never Published

I finally got tired of not knowing whether anyone reads this thing, so I spent a Saturday afternoon standing up Umami in the homelab. The install was the easy part; the instructions are dead simple.

Then I got to the bit where you expose it to the internet. I went along with the advice everyone gives, stopped halfway and decided it wasn't good enough for me. So, I did what I normally do and stubbornly wandered off to my own path.

Quick bit of context for anyone who hasn't gone down this particular hole. Umami is a self-hosted analytics platform: a small Node app with a PostgreSQL database to maintain persistence sans the cookies and third parties slurping up all the data. It's as simple as dropping one script tag on your site. It posts a little blob of JSON on each page view and you get a dashboard. My install runs in an LXC on a member node of my homelab's Proxmox cluster.

I landed on it for the "normal" reasons. Cloudflare's Analytics are free but limited. They don't give me precisely what I'm looking for and they're a bit too slow for my liking. And, I'm not even going to give Google a single thought; they have enough of my data already.

Which leaves the question: how does a script tag on a public website safely and securely reach a container in my house without punching a hole in my router?

The answer everyone gives you.

The standard answer is a Cloudflare Tunnel. A small daemon, cloudflared, runs next to your service and dials out to Cloudflare. No dicking around with port forwarding, firewall rules or opening up your router to the world. Traffic arrives at Cloudflare, goes down the pipe your daemon already opened and lands on your service. It's genuinely lovely and it costs nothing. And since I already use Cloudflare to currently host my static websites and domains it was just the pragmatic option.

I think it's worth noting there is a real trade off here. A tunnel means Cloudflare terminates your TLS. Since they decrypt at the edge, they could inspect whatever they like and they re-encrypt on the way down the pipe. For page view pings carrying a URL and a screen size, I genuinely do not care. For anything with secrets in the payload I would care enormously and so should you.

Anyway, Umami has an admin interface. An admin interface typically has a login page. So, most guides you'll find after searching around will tell you to put something like Cloudflare Access in front of it. Access is an authentication layer at the edge. Basically, someone hits your hostname, Cloudflare intercepts, they sign in against an identity provider and only then does the request continue to your box.

I was about to wire it to my IDP when I actually stopped to read my own ingress config a bit more closely.

Tunnels match on path.

The important little detail I almost skimmed past was that a tunnel's ingress rules do not just match on hostname, but on path.

I had written this, which is what everyone starts with:

ingress = [
  {
    hostname = "analytics.nightcity.network"
    service  = "http://umami:3000"
  },
  {
    service = "http_status:404"
  },
]
Enter fullscreen mode Exit fullscreen mode

You write a single hostname and everything on it goes straight to the app while anything else gets a 404. Ok.

But my analytics service only needs to expose two things to the public internet; the tracker script and the collect endpoint. Nobody on the internet needs to see /login or the dashboard, the settings, the user management or the API the dashboard talks to. So, why was I publishing everything and then buying into another service to place in front of it?

ingress = [
  {
    hostname = "analytics.nightcity.network"
    path     = "^/bundle\\.js$"
    service  = "http://nginx:80"
  },
  {
    hostname = "analytics.nightcity.network"
    path     = "^/api/v1/data$"
    service  = "http://nginx:80"
  },
  {
    service = "http_status:404"
  },
]
Enter fullscreen mode Exit fullscreen mode

I only need precisely two paths out while everything else gets met with a lovely 404.

My admin interface still exists, of course ( I am not typing SQL to read my own page views ). It sits on my home network behind my own reverse proxy, reachable from the couch or over my established VPN.

Drawn out, the whole arrangement is two seperate paths that happen to share a name:

flowchart TD
    N["analytics.nightcity.network"]
    N -->|asked from the internet| CD[Cloudflare DNS]
    N -->|asked from my LAN| TD[Technitium]
    CD -->|104.21.x.x| E[Cloudflare edge]
    TD -->|10.0.0.200| P[My reverse proxy]
    E --> C[cloudflared]
    C -->|/bundle.js| X[nginx]
    C -->|/api/v1/data| X
    C -->|everything else| F[404]
    X --> U[Umami]
    P --> U

cloudflared decides what gets through and it's only aware of two paths. My side of the picture never touches Cloudflare, or the rest of the internet, at all. That shared fork at the top is possible with split horizon DNS and which route your request takes depends on how you get to that point. I'll explain a bit further down.

Why I think this is the better trade.

I want to be fair to Access here, because it's a great product and there are plenty of setups where it's the right call. If you genuinely need to reach an admin panel from anywhere on a machine you don't control, without a VPN, Access is exactly the kind of tool that'll get you there.

But if you don't need that, consider what you're actually buying in to.

With Access, /login is on the internet. It returns a challenge instead of a form, which is much better than nothing, but the attack surface is still there. There's an auth flow to configure, an IDP to keep working and an additional sign-in every time. Umami doesn't consume the Access token, so you authenticate to Cloudflare and then authenticate again to Umami. Nobody has time for that nonsense.

With path scoping, /login returns a 404. There is no flow to misconfigure because there is no flow. There is nothing to leave accidentally open when you change something else eighteen months from now.

Elimination is the ultimate form of hardening.

— Pretty much every cybersecurity professional.

The thing I keep coming back to: an attack surface you removed cannot be misconfigured later. A control you added can. This is all about shrinking the attack surface, which is an idea that's far older than me. We're not doing anything new here. But a standard installation path is built to get the typical user up and running quickly. However, quick doesn't necessarily mean appropriate for something like this.

Worth being precise about which of these is which, though. The 404 on /login is absence. There is nothing behind it to find no matter how hard you look. Umami does allow you to rename its default tracker paths as well, but that's plainly obscurity and it buys me nothing against anyone who actually takes the time to read my page source. Outside of that, renaming dodges a blocklist that matches on names and known patterns.

Does it work though?

The nice part about this setup is that you can test the public path without leaving the house. Using curl --resolve lets you skip your own DNS and dial the Cloudflare edge directly, while still sending the right hostname, so the request takes the same road a real visitor does.

curl -sI \
  --resolve analytics.nightcity.network:443:104.21.x.x \
  https://analytics.nightcity.network/login | head -1
Enter fullscreen mode Exit fullscreen mode

Run that against a handful of paths and you get:

/                  404
/login             404
/bundle.js         200
/script.js         404
/api/v1/data       405
/api/send          404
Enter fullscreen mode Exit fullscreen mode

That 405 is the collect endpoint telling me it only accepts POST, which is exactly what I want to see from a GET. It does admit the endpoint exists, where a 404 would not, but that one has to be findable anyway. Everything else gets a polite middle finger.

Meanwhile, from inside the house, the same hostname gives me the full dashboard. The same two path as before and the split falls out of the DNS I already run for network-wide adblock via a Technitium cluster.

Something worth pointing out.

I updated the ingress config, re-ran my checks and /script.js came back with a 200. Which was alarming, because I had just watched it 404.

It was cached. Umami serves its tracker with a 24-hour cache header. My earlier testing had pulled it through the edge and Cloudflare was dutifully serving me the copy it already had. Which, to be fair, is exactly what it should normally be doing. A request with a junk query string came back 404 immediately confirming the origin was doing its job.

Worth knowing if you ever unpublish something: the edge does not find out until its copy expires. Cache invalidation strikes again!

Hark! A plot twist!

Just after I shipped the script tag and confirmed real traffic was hitting Umami, Firefox showed me this:

The Firefox popup for Local Network Access.
Go home Firefox, you are drunk.

Sir, this is my own site from my machine on my home network. How dare you?

As I mentioned earlier, I run split horizon DNS. Inside my network, analytics.nightcity.network resolves to a private address on my LAN so I get the dashboard directly. Outside, where you better be reading from, the same name resolves to Cloudflare and goes down the tunnel. That's what makes the two-path thing work. I also mentioned this before in my previous article My Blog Now Ships From My Homelab! and it's one of my favourite aspects about my setup.

But when I load my public blog from my own house, the browser sees a page served from a public origin trying to load a subresource from 10.x.x.x. Browsers have started treating that as exactly the attack it usually is: a website on the internet quietly poking at your router, your printer or your NAS. Firefox calls it Local Network Access and blocks it by default. Chrome is heading the same way.

So the one place on earth where my analytics silently do not work is the chair I'm sitting in which is precisely how it should work. If it ever bothers me, the fix is to give the tracker its own hostname in a domain my internal resolver doesn't answer for, so it goes out to the edge from everywhere including my lounge room. I won't be doing that as I'm all too happy skipping my own page views.

The one thing path scoping does not fix.

Moving the admin interface off the internet does nothing for the collect endpoint. That endpoint has to accept anonymous POSTs from every visitor's browser, or it isn't an analytics endpoint. Umami rejects requests carrying a website ID it doesn't recognise, which stops idle nonsense, but my real website ID is sitting in the page source of every page on this site and Plant Smart. Literally anyone can read it.

So the only real control is applying some kind of rate limit. Cloudflare's free tier, which I'm currently on because I'm cheap and actually trying to reduce my subscription costs, permits one rule at a ten second window and it insists on counting per data centre rather than globally. It'll stop something stupid. But, it will not stop something determined.

Basically, path scoping shrinks the surface area enormously and then stops. What's left is the stuff that's supposed to be open. Though, the worst that can happen if the service gets hammered is fill my disk with garbage data points. Which would no-shit make me laugh.

... so, naturally I put Nginx in front of it.

I told myself this was about the rate limit which was a lie. It took a few hours for two better reasons to turn up.

The origin is blind.

A tunnel terminates the connection at the edge and opens a fresh one to your box, so every request arrives from the connector sitting next to your service. Umami sees 172.16.x.x, the Docker address of cloudflared, for the entire internet. That's also why the usual advice of "just put fail2ban on it" doesn't really apply. Fail2ban blocks source addresses with a firewall rule and no packet ever arrives from the address you'd want to block.

Cloudflare does pass the real client along in a CF-Connecting-IP header. Nginx can promote that header back into being the actual client address, which makes both the logging and the limiting mean something:

set_real_ip_from 172.16.0.0/12;
real_ip_header   CF-Connecting-IP;

limit_req_zone $binary_remote_addr zone=collect:10m rate=5r/s;
Enter fullscreen mode Exit fullscreen mode

The set_real_ip_from range tells Nginx only the connector may assert that header. Nginx answers cloudflared and nothing else here and my internal route goes straight to Umami without passing through it. Meaning nothing on my LAN can forge a client address at me.

Umami logs nothing at all. Zilch.

Its own logs are four lines of startup and then a blank stare into the void. Which means that until I put a proxy in front of it, the only record of what hit my analytics endpoint was... my analytics.

Nginx gave me an access log within about ten seconds of starting and the very first thing it told me was that I had no idea what my traffic was.

In which I am humbled by a robot.

I had been suprised with my initial visitor numbers. Then I looked properly and roughly ~90% of it was a single machine in Singapore hammering one tiny post:

browser  os      device  screen      country  hits  sessions
chrome   Mac OS  laptop  1366x1366   SG       53    51
Enter fullscreen mode Exit fullscreen mode

There are a few tells here. Not only is the screen size a perfect square, 50ish sessions with just as many hits from the same region with the same browser settings smells like a bot crawl. It was Kagi, near as I can tell, rendering the page to build a preview for Kagi Small Web. I have no real objection to that. It reaches my tracker at all because it runs the JavaScript, which an ordinary crawler never would. This is exactly the sort of thing that pollutes a dataset while you sit there feeling popular.

I don't care about the bots. The point is that I only found it because I'd added logging. I only added logging because I was chasing a rate limit I probably didn't need.

The small detail I liked most.

Nginx has a default location block and mine looks like this:

location / {
    return 444;
}
Enter fullscreen mode Exit fullscreen mode

444 is an Nginx special. It doesn't return a status, but closes the connection quietly.

That rule is doing the same job as the catch-all 404 in my tunnel ingress. It holds exactly the same line only hop further in. Two independent things now have to be wrong at once before anything reaches my admin interface.

So, the tunnel is Cloudflare's. The edge rate limit is Cloudflare's. The Nginx config is a text file that's mine and it doesn't care in the slightest what is in front of it. Which matters, because I'm not 100% sold on keeping Cloudflare around longer-term.

None of the idea is Cloudflare-specific either. Any reverse proxy has a path-scoped location block and any tunnel worth the name matches on more than a hostname. Tailscale, Pangolin, NetBird or even vanilla WireGuard will get you there. I picked Tunnels as a convenient stop-gap until I move on to something a bit more "self-hosty". The vendor is incidental. Publish only what has to be public.

In closing ...

None of this is novel. Tunnels have matched on path since forever, it's in the docs and I'm certain plenty of people are already doing exactly this. I just hadn't thought about it properly, because the well-trodden advice is "put an auth layer in front of it" and this kind of consensus is usually good enough that you stop thinking.

But "protect the admin panel" and "publish the admin panel and then protect it" are not the same sentence and you should be aware of the difference.

If you're running anything through a tunnel right now, it's worth five minutes with your ingress config. Ask yourself which paths actually need to leave your network. For a lot of self-hosted things the honest answer is "fewer than all of them". The best answer is "none". And if you'd rather find out than wonder, that curl --resolve trick works against any hostname, including yours. Start with /login and see what comes back.

And if you're about to tell me the browser permission dialog is a bug, it isn't. It's three correct systems arguing and I've decided that's a feature.

Would you like to know more?

I kept the theory light in the post, because it kind of goes against the vibe of the site and my own casual style of writing. But the ideas underneath this are old and much better argued elsewhere, so here's where I'd start.

  • The Protection of Information in Computer Systems, Saltzer and Schroeder, 1975. The source of fail-safe defaults ( deny by default, allow by exception, which is exactly what that catch-all 404 rule is ) and economy of mechanism ( fewer moving parts means fewer parts to get wrong ). It's a fifty year old paper and it reads like one, but section I is short and it has aged as disgracefully as I have.
  • Attack Surface Analysis, OWASP. The plain-English version of the whole post, minus my nonsense. Useful if you want a structured way to ask "what am I actually exposing" about something larger than a script tag.
  • Tunnel configuration file, Cloudflare. The ingress rules reference. This is the page I had skimmed past twice before noticing that path was sitting right there next to hostname.
  • Local Network Access, WICG. The spec behind the permission dialog that made me raise an eyebrow. If you'd rather have prose than a spec, Chrome's New permission prompt for Local Network Access covers the same ground and explains why browsers decided this needed fixing.

If you only read one, make it Saltzer and Schroeder. Almost everything I thought I worked out on that Saturday turns out to be in there, but described better by people who got to it first and who are far smarter than me.

Top comments (0)