Skip to content

fix: test permission bits instead of comparing the secret file mode numerically - #206

Merged
EyJunge1 merged 1 commit into
tickernelz:mainfrom
LHMQ878:fix/secret-file-permission-bitmask
Jul 30, 2026
Merged

fix: test permission bits instead of comparing the secret file mode numerically#206
EyJunge1 merged 1 commit into
tickernelz:mainfrom
LHMQ878:fix/secret-file-permission-bitmask

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #205

What was wrong

checkFilePermissions decided whether a file:// secret is exposed with mode > 0o600 — comparing a bitmask as an integer. Permission bits are three independent 3-bit fields, and their numeric ordering does not track how exposed a file is, because a change in the owner field outweighs any change in the group/other fields.

mode symbolic meaning before after
0600 rw------- owner only no warn no warn
0400 r-------- owner read only no warn no warn
0700 rwx------ owner only warn no warn
0644 rw-r--r-- world-readable warn warn
0640 rw-r----- group-readable warn warn
0404 r-----r-- world-readable no warn warn
0060 ---rw---- group-writable no warn warn
0006 ------rw- world-writable no warn warn
0007 ------rwx world-rwx no warn warn
0077 ---rwxrwx group+other everything no warn warn
0777 rwxrwxrwx everything warn warn

Six of eleven were classified wrongly. The sharpest pair: 0404 is world-readable and was silent, while 0640 exposes strictly less and warned.

resolveSecretValue is the accessor for memoryApiKey, embeddingApiKey, webServerAuthPassword and webServerApiToken (src/config.ts:563, :586, :620, :623). This warning is the only feedback a user gets that a credential file is reachable by other accounts on the machine.

What changed

const GROUP_AND_OTHER_BITS = 0o077;
const exposedBits = mode & GROUP_AND_OTHER_BITS;
if (exposedBits !== 0) { /* warn */ }

Owner bits stay excluded, so 0600, 0400 and 0700 are all accepted — the question is whether anyone else can reach the secret, which also removes the spurious 0700 warning.

The message now zero-pads the mode. 0o006 previously rendered as (6), and since the message is the entire signal the user receives it should name the mode unambiguously. It also says why — "granting group/other access".

Deliberately unchanged: the win32 early return (POSIX mode bits are not meaningful there), and the fact that the check only advises. It must never block a resolution that would otherwise succeed, or a permission nit would take the plugin down — that has a test of its own.

Scope note

This is advisory output, not an access control. The file is already readable by whoever the mode allows, and this change does not alter who can read it. What it fixes is a check that reported "fine" for the exact configuration it exists to flag.

Verification

  • bun test tests/secret-resolver-permissions.test.ts: 19 pass / 0 fail.
  • With src/services/secret-resolver.ts reverted to main and the new tests kept: 8 fail / 11 pass. One of those failures is the false positive (0700 warning needlessly), so the tests pin both directions.
  • Full bun test: 297 pass / 9 fail vs baseline 278 / 9 — exactly +19, no new failures. (Those 9 are pre-existing here: plugin-loader-contract and plugin-bundle-boundary require a built dist/.)
  • tsc --noEmit: clean. prettier --check: clean. The husky pre-commit hook ran both and passed.

Tests added

tests/secret-resolver-permissions.test.ts — 19 cases, using the spyOn(fs, …) style already used in tests/config-resolution.test.ts:

  • it.each over 5 owner-only modes (0600, 0400, 0200, 0700, 0000) asserting no warning — this is what catches the 0700 false positive
  • it.each over 10 group/other-accessible modes asserting a warning, including the five that sort numerically below 0o600
  • the 0404 vs 0640 pair asserted together, since the bug is most legible as a contradiction between them
  • the mode is rendered zero-padded ((006), not (6))
  • win32 still skips the check entirely
  • the secret is still returned when the warning fires — the check advises, it does not block

statSync is mocked with the file-type bits set (0o100000 | mode) so that the & 0o777 mask in the source is what strips them, rather than the test quietly handing over pre-masked input.

`checkFilePermissions` used `mode > 0o600` to decide whether a `file://` secret
is exposed. That compares a bitmask as an integer, which is unrelated to whether
any group or other bit is set: a change in the owner field outweighs any change
in the group/other fields.

So 0404 (world-readable), 0006 (world-writable), 0007, 0060 and 0077 all warned
about nothing, while 0640 -- which exposes strictly less than 0644 -- did warn,
and 0700, which exposes nothing at all, warned needlessly. Six of eleven
representative modes were classified wrongly.

Mask with 0o077 and test for a non-zero result. Owner bits stay excluded, so
0600, 0400 and 0700 are all accepted; what matters is whether anyone else can
reach the secret.

Also zero-pad the mode in the message, since 0o006 rendered as "(6)" and the
message is the only signal the user gets.

The win32 early return is unchanged, and the check still only advises -- it
never blocks a resolution that would otherwise succeed.
@EyJunge1
EyJunge1 merged commit 6c3b986 into tickernelz:main Jul 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

secret file permission check misses world-readable modes: mode > 0o600 compares a bitmask as an integer

2 participants