DEV Community

Daniel Pertu
Daniel Pertu

Posted on

1,097 files in our app's data folder are mode 0600 and 19 are 0644, and the 19 are the ones with secrets in them

Notifio is a desktop app that watches rental search pages and alerts you when a listing appears. Almost everything it knows lives on your machine rather than ours, which our privacy policy is specific about: it names the folder and tells you that deleting it removes the lot.

A couple of weeks ago I counted the bytes in that folder and found that everything the app knows about me is 13,026 bytes inside a 63 MB directory. This post counts something else about the same folder: the permission bits. Different question, much worse answer.

cd ~/Library/Application\ Support/Notifio
find . -type f -exec stat -f "%Sp" {} \; | sort | uniq -c | sort -rn
find . -type d -exec stat -f "%Sp" {} \; | sort | uniq -c | sort -rn
Enter fullscreen mode Exit fullscreen mode
1097  -rw-------      173  drwx------
  19  -rw-r--r--        3  drwxr-xr-x
Enter fullscreen mode Exit fullscreen mode

1,116 files, 176 directories. Nineteen files any user on the machine can read, and three directories any user can list. I did not write a single one of those modes down anywhere, which is the point: nothing in the code chooses them.

Where 0644 comes from when you never ask for it

Every write in the app looks like this:

fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2), 'utf8');
fs.renameSync(tmp, CONFIG_PATH);
Enter fullscreen mode Exit fullscreen mode

Write to a temp file, rename over the target, so a crash mid-write cannot leave half a config behind. Good pattern, and it has nothing to say about permissions. There are 15 filesystem write or create calls in the app:

10   writeFileSync / appendFileSync   create a file
 4   mkdirSync                        create a directory
 1   copyFileSync                     seed config.json on first run
Enter fullscreen mode Exit fullscreen mode

Plus the renameSync calls, which carry whatever mode the temp file was created with.

grep -rn "mode:\s*0o\|chmod" src/
# none
Enter fullscreen mode Exit fullscreen mode

Zero of the 15 pass a mode. So Node uses its defaults, 0o666 for a file and 0o777 for a directory, each masked by the process umask, which on a normal macOS login is 022:

0666 & ~022 = 0644
0777 & ~022 = 0755
Enter fullscreen mode Exit fullscreen mode

That arithmetic is the entire explanation for the right-hand column of the census. The three drwxr-xr-x directories are data, browser-data and ledger, the three our startup code creates. Every other directory in there was made by Chromium and is 0700.

The 19, and the part that is genuinely embarrassing

21,891  browser-data/kamernet_nl_cookies.json
 6,106  data/04dd368adb1eb9b4.json
 2,168  data/79a9bdcf8f289831.json
 1,804  data/0845b35511aa4f5c.json
 1,725  logs.txt
 1,117  config.json
   106  entitlement.json
   ---
    12  bookkeeping files inside the two browser profiles
Enter fullscreen mode Exit fullscreen mode

Seven of those are ours. The other twelve are Chromium's own Variations, Last Version, ChromeFeatureState and friends, two sets of six, one per site profile.

And that is the detail that turns this from a shrug into a finding. Chromium is not careless about modes. It writes Variations and Last Version as 0644 because they are build bookkeeping, and it writes Cookies, Local State, Trust Tokens and the rest of its 1,097 files as 0600 because they are not. It makes a decision per file. Our code makes no decision at all, so the two categories come out identical, and the files that land on the wrong side of it are:

  • config.json, which holds your licence email and licence token. The same privacy policy that names the folder also says of that token: "Treat it like a password: anyone who has both your email address and your token could activate a copy of the app in your name."
  • kamernet_nl_cookies.json, the session cookies for a site you logged into, as plain JSON. 21,891 bytes, which is 58 per cent of all the world-readable bytes in the tree.
  • the three data/*.json files, which are the last-seen listing results per search, so they describe what you are looking for and where.
  • logs.txt, the app's own activity log.
  • entitlement.json, a cached yes or no about the auto-reply upgrade, and the one file here where I genuinely do not care.

Why it has not mattered, measured rather than assumed

stat -f "%Sp %N" . .. ../..
Enter fullscreen mode Exit fullscreen mode
drwx------  .        (Notifio)
drwx------  ..       (Application Support)
drwx------  ../..    (home directory)
Enter fullscreen mode Exit fullscreen mode

Every directory above those files denies other users even the right to traverse. Electron created the userData directory 0700 before any of our code ran, macOS created Application Support and the home directory the same way, and a 0644 file inside a 0700 chain cannot be opened by another user, because they cannot get to it. The permissive bits sit behind a door that is already locked.

So the honest summary is that this is not a live vulnerability on a stock single-user Mac. It is a missing guarantee, and the difference shows up the moment the file leaves the chain:

  • A support bundle. The day I add "zip your data folder and send it over", that zip carries the modes.
  • A restore. Copying a tree to an external disk and back, or onto a volume with no permission semantics, flattens or re-derives modes from the copier's umask, and the only thing that stays wrong is a file that was wrong already.
  • A shared or managed Mac where someone has loosened Application Support, or a home directory on a network volume.
  • Windows, where this census is a different question entirely: %APPDATA%\Notifio inherits user-only ACLs, and Node's mode argument is ignored there apart from the read-only flag. The fix below buys nothing on Windows and costs nothing either.

What a mode never defends against is any other process running as you. That is what encryption is for, and it is why the one file in that folder with a real secret in it, the reply profile, is written through a safeStorage envelope using the operating system's credential store, exactly as the privacy policy says. The licence token is not in that envelope, because the token is also sitting in your purchase email, and encrypting a value that arrived by email is theatre.

The fix is 15 arguments

fs.writeFileSync(tmp, data, { encoding: 'utf8', mode: 0o600 });
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 });
Enter fullscreen mode Exit fullscreen mode

Plus one pass at startup that chmods anything already on disk, because every install out there has 0644 files in it today and a new default fixes only new files. That is the whole change: no new dependency, no migration, no user-visible behaviour.

The reason to write it up rather than quietly ship it is the shape of the mistake, which is not "we chose badly". We never chose. Fifteen calls took a default from a bitmask set by the login shell, and the result was a security property of the product that no line of code states and no test could fail on. If your app writes anything to disk, the census takes about ten seconds:

find "$DATA_DIR" -type f -perm -004 -exec stat -f "%Sp %z %N" {} \;
Enter fullscreen mode Exit fullscreen mode

Anything in that list with a credential in it is a decision you did not make.

Top comments (0)