DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our desktop app serves its own UI over HTTP, and 127.0.0.1 is the only auth on 32 routes

Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. It is an Electron app, so the obvious architecture is a main process, a preload script, a contextBridge surface and a pile of ipcMain.handle calls.

It is not built that way. The main process starts an Express server in-process, and the window is a browser pointed at it:

mainWindow.loadURL(`http://localhost:${serverPort}`);
Enter fullscreen mode Exit fullscreen mode

The renderer does not know it is in Electron at all. It talks to its own origin:

// The renderer is served by the embedded Express server, so talk to it on the
// same origin. This keeps things working if PORT is changed from the default.
const BASE =
  typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
Enter fullscreen mode Exit fullscreen mode

ipcMain appears in exactly one file in the repository, and it belongs to the auto-reply recorder window. The main window, the one with every search and every control in it, uses zero IPC and has no preload script at all. The UI is a web app that happens to be rendered inside a window we own, and express.static serves the Vite build out of public/.

What is actually exposed

The embedded server is 775 lines and 32 routes:

Method Count Examples
GET 10 /api/events, /api/config, /api/sites, /api/monitor/status
POST 15 /api/license/activate, /api/monitor/start, /api/monitor/stop
PATCH 4 /api/config/notifications, /api/sites/:index, /api/autoreply
DELETE 3 /api/sites/:index, /api/finds, /api/recipes/:id

None of them authenticate anything. There is no session, no token, no Origin check and no CORS configuration, because the only client that was ever meant to exist is a window the same process opened.

The one line that makes that defensible is the bind address:

const PORT = process.env.PORT || 3000;

// Bind to loopback only. This API exposes the license email/token and full
// monitor control with no auth, so it must never be reachable from the LAN.
const server = app.listen(Number(PORT), '127.0.0.1', () => {
  console.log(`Notifio running at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

app.listen(port) without a host binds to every interface. On a laptop that spends its life on café and student-house wifi, that difference is the whole security model: it is the difference between "a process on this machine" and "anyone on this network". The comment above it is the kind I now insist on, because the argument is easy to delete and impossible to miss when it is explained.

The route that is careful, and the route next to it that is not

Two routes read the same config.json. One of them is careful:

app.get('/api/license', (req, res) => {
  const cfg = loadConfig();
  const license = cfg.license;
  if (!license?.email || !license?.token) {
    return res.json({ activated: false });
  }
  res.json({ activated: true, email: license.email });
});
Enter fullscreen mode Exit fullscreen mode

The activation screen needs to know whether a licence exists and which address it belongs to, so that is exactly what it returns. The token stays out of the response.

And one of them is not:

app.get('/api/config', (req, res) => {
  res.json(loadConfig());
});
Enter fullscreen mode Exit fullscreen mode

That is the whole config file, activation code included.

I am not going to pretend that one is a vulnerability. Anything that can reach loopback is already running as the user who owns the file, and that file is plain JSON in their own data directory. A local app cannot keep a secret from its owner, so a token behind a loopback port is not meaningfully protected by the port. What it is, though, is a route that hands out a credential to any caller that asks, when the UI never needed the credential in the first place. And the renderer, which is the only caller, reads exactly one field out of that response:

if (configRes.ok) {
  dispatch({
    type: "desktop_notifications",
    enabled: configRes.data.notifications?.desktop !== false,
  });
}
Enter fullscreen mode Exit fullscreen mode

One boolean. The config file is sent whole, including the licence token, to answer whether the desktop banner is switched on. Dropping license from that response costs one line and removes the question entirely.

What loopback does not cover

Loopback stops the network. It does not stop the browser you already have open.

A web page cannot read a response from http://localhost:3000 without CORS headers, and our server sends none, so nothing can be exfiltrated that way. But reading is not the only thing that matters. A plain HTML form does not need permission to be submitted cross-origin, and 6 of those 15 POST routes never look at the request body at all, so an unparsed body costs them nothing:

app.post('/api/monitor/stop', (req, res) => {
  monitor.stop();
  res.json({ ok: true });
});
Enter fullscreen mode Exit fullscreen mode

POST /api/license/deactivate is the same shape, and it deletes the licence from config.json and stops monitoring. Browsers have been tightening this ground for a while, with Private Network Access preflights and now permission prompts for public pages reaching into a private address, and a fetch() from an HTTPS page would hit a preflight our server fails. I would not want to describe any of that as our access control, though, because none of it is ours and the behaviour differs per browser and per version. What stands between a visited page and your monitor silently stopping should be in our repo.

The fix is small enough that writing the paragraph took longer:

// The only legitimate caller is the window we opened, which is same-origin.
app.use((req, res, next) => {
  if (req.method === 'GET') return next();
  const origin = req.get('Origin');
  if (origin && origin !== `http://localhost:${PORT}`) {
    return res.status(403).json({ error: 'Cross-origin request refused' });
  }
  next();
});
Enter fullscreen mode Exit fullscreen mode

That is the thing I like about auditing an app by counting its routes. 32 is a small enough number to list, and listing them is what makes "no auth" stop sounding like a design decision and start sounding like a question.

The failure I had not thought about at all

There is no server.on('error') handler anywhere in the file.

Port 3000 is the single most contested port on a developer's machine. If something else already holds it, listen emits an error with no listener attached, Node turns that into an uncaught exception, and the only thing waiting for it is the file logger in main.ts:

process.on('uncaughtException', (err: Error) => {
  logFile(`UNCAUGHT: ${err.stack || err.message}`);
});
Enter fullscreen mode Exit fullscreen mode

Startup then carries on, because the thing that gates the window is a two-second timer rather than a listening event. createWindow() runs, and loadURL('http://localhost:3000') loads whatever else is on port 3000. Notifio's window would show somebody else's dev server, and the only record of why would be one line in logs.txt, which the crash window already tells you to go and read.

The real fix is to stop naming a port: listen on 0, let the OS pick a free one, and pass the port the server actually got to loadURL. The reason it has not happened yet is that the renderer already reads window.location.origin rather than a constant, so the UI does not care what the port is. The only thing that still hardcodes 3000 is the window.

Why keep the HTTP boundary

Having written all of that down, the architecture still earns its place.

The monitor runs in the main process because it drives a real browser and reads files. The UI is React. Something has to carry per-search state and a live log between them, and an HTTP plus Server-Sent Events boundary gives three things IPC does not: the UI runs unchanged in a normal browser during development, the state is observable with curl, and the renderer needs no Electron-aware code at all, which is one of the four ways this codebase avoids importing Electron.

The cost is that you have built a server, and a server has an attack surface whether or not you meant to design one. Two lines of middleware and one 0 instead of 3000 is a cheap price for keeping the development experience.

You can see the product the routes are serving from the outside: the download page has the Mac and Windows builds, the pricing page is the one-time licence the activation routes check, and the per-site setup guides live at notifio.app/alerts, including Kamernet, Rightmove and SpareRoom. Setup questions are answered on the help page.

Top comments (0)