DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our privacy policy names the two fields our app sends us, and the alert request has nine

Notifio is a desktop app that watches rental search pages from your own machine and tells you the moment a new listing appears. Almost everything it knows stays on your disk, which is the whole pitch, and the privacy notice is written to say so in plain terms rather than in legalese.

Section 2 ends with this sentence:

The only data the desktop app sends to our server is your email address and licence token, to validate your licence and to request alert and report emails.

I went through every outbound request in the app this week and that sentence is not true. It is not false in an alarming way, nobody's searches are being hoovered up, but the request that produces an alert email carries nine JSON keys and the policy names two of them.

Here is the audit, because a privacy policy is a specification of your own storage and network behaviour, and specifications drift.

Every place the app calls our server

Six call sites, all of them to notifio.app:

Where Endpoint Why
entitlement.ts POST /api/validate is this licence real, and is the upgrade bought
server.ts POST /api/validate the same check during activation
entitlement.ts POST /api/checkout/upgrade start a Stripe session for the upgrade
monitor.ts POST /api/notify a listing alert
monitor.ts POST /api/notify this site wants you to log in again
monitor.ts POST /api/notify this site is refusing automated checks
notify.ts POST /api/notify an auto-reply went out

The base URL is one function, which is how I could be confident the list is complete:

export function serverUrl(): string {
  return (process.env.NOTIFIO_SERVER_URL || 'https://notifio.app').replace(/\/$/, '');
}
Enter fullscreen mode Exit fullscreen mode

Two of those endpoints really are email and token only. POST /api/validate sends exactly what the sentence says:

const res = await fetch(`${serverUrl()}/api/validate`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, token }),
  signal: AbortSignal.timeout(10_000),
});
Enter fullscreen mode Exit fullscreen mode

The upgrade checkout adds one field, the currency the app displayed, which exists so the server charges the price the user actually read rather than a figure it resolves on its own.

And then there is the alert.

What a listing alert carries

const res = await fetch(`${serverUrl}/api/notify`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email: license.email,
    token: license.token,
    type: 'listings',
    alerts,
  }),
  signal: AbortSignal.timeout(15_000),
});
Enter fullscreen mode Exit fullscreen mode

alerts is an array of this:

export interface EmailAlert {
  siteName: string;
  newListings: Listing[];
}

export interface Listing {
  id: string;
  title: string;
  url: string;
}
Enter fullscreen mode Exit fullscreen mode

Count the keys that reach the wire: email, token, type, alerts, siteName, newListings, id, title, url. Nine. Seven of them are not in the sentence, and three of them are the content of somebody's housing search: the name of the site, the title of the room, and a link straight to it.

The other three notify types are smaller and still outside the sentence. A login prompt sends authFailureSite, a refusal sends blockedSite, and a completed auto-reply sends the site name, the listing title, the listing URL and a boolean saying whether the site confirmed the submission.

One thing worth noticing in passing: the server's own type for a listing declares two fields.

export interface AlertListing {
  title: string;
  url: string;
}
Enter fullscreen mode Exit fullscreen mode

The app sends three. id is the listing's origin plus path, used locally so the same room is never alerted twice, and it rides along to a server that has no interest in it and no field for it. That is a one-line fix at the call site and it is the easiest part of this whole post to action.

Why the server has the listing at all

This is the part I do not want to change, so it is worth defending.

The email is rendered on the server because the licence check and the mail provider's key both live there. If the app composed the message and posted the finished HTML, /api/notify would be an authenticated relay that sends arbitrary markup to any address a caller names, and the authentication on it is a licence token sitting in a plain JSON file on the caller's own machine. That is a spam gateway with a 20 pound entry fee. Keeping the template on our side means the app can only ask for one of four specific emails, addressed to the licence holder and nobody else.

So the listing has to cross the wire. What the route does with it is the part that keeps this honest: it looks the licence up, hands the titles and links to the template, and returns.

const [license] = await db
  .select()
  .from(licenses)
  .where(eq(licenses.token, token))
  .limit(1);

if (!license || license.email !== email || !license.active) {
  return NextResponse.json({ error: "Invalid or inactive license." }, { status: 403 });
}
Enter fullscreen mode Exit fullscreen mode

There is no insert and no update anywhere in that file. The listing exists in our process for as long as it takes to build an email and then it exists in the user's inbox, which is where they wanted it. Our database is one table and it has no column that could hold a listing even by accident.

That is a fine design. It is just not the design the policy describes, and "we do not keep it" is a different promise from "we never receive it". The policy makes the second one.

Drift two: a file the app stopped writing

The device-only list in section 2 includes these bullets:

Your reply profile, the details you enter for Auto-Reply to submit, which may include your name, email, phone number, date of birth, nationality, occupation, employer, income, household size, and any custom fields you add yourself

The message you send to landlords

Both describe storage that no longer exists. The settings module that used to hold them now holds this:

function defaults(): UserData {
  return { version: DATA_VERSION, autoReply: { enabled: false } };
}
Enter fullscreen mode Exit fullscreen mode

And its own header says what happened:

 * The auto-reply on/off switch. That is the entire settings surface.
 *
 * Replaces the old profile store, and then the shared message: neither is needed,
 * because a recording already contains everything that site's form asks for,
 * message included.
Enter fullscreen mode Exit fullscreen mode

The migration is a one-way door. Anything that is not the switch is dropped on read:

function migrate(raw: unknown): UserData {
  const data = (raw ?? {}) as { autoReply?: Partial<AutoReplySettings> };
  return {
    version: DATA_VERSION,
    autoReply: { enabled: data.autoReply?.enabled === true },
  };
}
Enter fullscreen mode Exit fullscreen mode

So a reader of the policy is being warned about a file holding their income and date of birth, and the app has not had a central place to put those for two refactors. The information that replaced it is covered by the bullet underneath, about recorded reply flows, which is accurate. The two stale bullets are over-disclosure rather than under-disclosure, which is the harmless direction, but they are still a description of software that is not the software.

Drift three: the comment that says plaintext and the code that encrypts

Section 2 also says this:

Your reply profile is encrypted at rest using your operating system's own credential store (Keychain on macOS, DPAPI on Windows), so it is not readable as plain text on disk.

True, and true of more files than the policy claims, because the recordings file goes through the same envelope:

export function writeEnvelope(filePath: string, value: unknown): void {
  const json = JSON.stringify(value);
  const envelope: Envelope = canEncrypt()
    ? { v: 1, enc: 'safeStorage', data: safeStorage.encryptString(json).toString('base64') }
    : { v: 1, enc: 'none', data: json };

  const tmp = `${filePath}.tmp`;
  fs.writeFileSync(tmp, JSON.stringify(envelope), 'utf8');
  fs.renameSync(tmp, filePath);
}
Enter fullscreen mode Exit fullscreen mode

Meanwhile the module that stores recordings opens with this:

 * Stored as plain JSON (no PII: recorded values are masked at capture time) so
 * recipes stay portable and can eventually be shipped as curated data for
 * popular sites.
Enter fullscreen mode Exit fullscreen mode

It calls readEnvelope and writeEnvelope on the next screen. The comment describes the format we want for shipping curated recordings later, the code describes the format on the user's disk today, and the two sentences read as a contradiction to anyone who greps for "plain". The privacy notice is accidentally more correct than the source comment, which is a funny place to end up.

For what it is worth, the encryption is an at-rest measure and nothing more. A local app cannot keep a secret from its owner, and the policy does not pretend otherwise: section 8 says the licence token is stored readably on our side so support can resend it.

The part that is right, and why

Section 6 tells you how to delete everything:

Windows: %APPDATA%\Notifio
macOS: ~/Library/Application Support/Notifio

Both correct, because every writable path in the app resolves through one module and that module's header spells those two directories out. A policy sentence that quotes a path has the same problem as a comment that quotes a constant: it rots the moment somebody renames something. This one has survived because the name it depends on is load-bearing in four other places and nobody is going to change it quietly.

That is the pattern in all four findings. The claims that stayed true are the ones pinned to something the code cannot change without breaking. The claims that drifted are the ones describing a shape, and shapes change in refactors that feel local.

What I am doing about it

The sentence gets replaced, not softened:

The desktop app sends us your email address and licence token to validate your licence. When it asks us to send you an alert, it also sends the name of the site and the title and link of each new listing, because the email is written on our server and those are what go in it. We do not store any of it: the request builds one email to your own address and nothing is written to our database.

Then the two stale bullets come out of the device-only list, the recordings bullet stays, the id field stops being sent to a server with no use for it, and the header comment in the recordings module gets a second sentence saying the file on disk is encrypted.

The last change is the one I would recommend to anybody shipping a product with a privacy page: put the policy's section number in a comment above the request that the section describes. A notice written in one repository and a fetch written in another will drift, and the only cheap defence is making the drift visible from the code side, where somebody is standing when they add the field.

If you want to read the thing being audited, it is at notifio.app/privacy, with the terms next to it and the data questions answered in plainer language on the help page. The app itself is a one-time licence, the per-site setup pages are at notifio.app/alerts including Kamernet and Rightmove, and the installers are on the download page.

Top comments (0)