Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/docs/docs/getting-started/desktop-app-updates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ If none of the above fit your tooling, write a JSON file containing
| Windows | `C:\ProgramData\Jetstream\policy.json` |
| macOS | `/Library/Application Support/Jetstream/policy.json` |

On Windows the file must be put in place with administrator rights - from an elevated prompt or by
your deployment tooling, which makes the Administrators group or SYSTEM the owner. Jetstream only
honors it when both the file and the `C:\ProgramData\Jetstream` folder are owned that way and neither
grants every user write access, so a standard user cannot place or edit one to change the policy for
everyone else on a shared computer.

### Avoiding the update prompt on managed Windows machines

If you deploy Jetstream for all users and do **not** disable updates, users without administrator
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
import { fromAppState } from '@jetstream/ui/app-state';
import { useAtom, useAtomValue } from 'jotai';
import { useAtomCallback } from 'jotai/utils';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { desktopUserPreferences, desktopUserPreferencesSyncState } from '../core/AppDesktopState';

Expand Down Expand Up @@ -56,9 +56,28 @@ const titleBarCss = css`
`;

const downloadPathCss = css`
word-break: break-all;
display: block;
overflow-wrap: anywhere;
`;

/**
* A folder path with a line-break opportunity after every separator, so a long path wraps at folder
* boundaries instead of mid-word. The `overflow-wrap: anywhere` on the container still covers a single
* segment that is longer than the line.
*/
function BreakablePath({ path }: { path: string }) {
return (
<>
{path.split(/(?<=[\\/])/).map((segment, i) => (
<Fragment key={i}>
{segment}
<wbr />
</Fragment>
))}
</>
);
}

export const Settings = () => {
useTitle(TITLES.SETTINGS);
const isMounted = useRef(true);
Expand All @@ -70,6 +89,7 @@ export const Settings = () => {
const ability = useAtomValue(fromAppState.abilityState);
const preferences = useAtomValue(desktopUserPreferences);
const [updatePolicy, setUpdatePolicy] = useState<UpdatePolicy | null>(null);
const [isChoosingDownloadFolder, setIsChoosingDownloadFolder] = useState(false);

const recordSyncEntitled = ability.can('access', 'RecordSync');
const downloadPath = preferences?.fileDownload?.downloadPath;
Expand Down Expand Up @@ -160,12 +180,44 @@ export const Settings = () => {
});
}

async function handleChooseDownloadFolder() {
const selectedPath = await window.electronAPI?.selectFolder();
// Nothing is returned when the folder selection is canceled
if (selectedPath) {
savePreferences({ fileDownload: { omitPrompt: true, downloadPath: selectedPath } });
}
// The folder dialog runs in the main process, which stores the choice itself. The renderer only ever
// receives the chosen path back for display - it can never supply one (a renderer-chosen path would let
// any download be written anywhere without a prompt), and the main process drops the field from anything
// the renderer sends. Queued behind pending saves so the stored preferences it returns are never older
// than a save in flight. Both download buttons stay disabled until it settles: a change queued behind the
// dialog would be shown, then replaced on screen by the folder the dialog returns, even though the queued
// change is what ends up stored.
function handleChooseDownloadFolder() {
setIsChoosingDownloadFolder(true);
saveQueueRef.current = saveQueueRef.current.then(async () => {
try {
if (!window.electronAPI) {
return;
}
const savedPreferences = await window.electronAPI.pickDownloadFolder();
// Nothing is returned when the folder selection is canceled
if (!savedPreferences) {
return;
}
savedPreferencesRef.current = savedPreferences;
mergePreferences({ fileDownload: savedPreferences.fileDownload });
Comment thread
paustint marked this conversation as resolved.
setUserProfile((prev) => ({
...prev,
preferences: savedPreferences,
}));
trackEvent(ANALYTICS_KEYS.settings_update_user);
} catch (ex) {
logger.warn('Error choosing the download folder', ex);
fireToast({
message: 'There was a problem updating your settings. Try again or file a support ticket for assistance.',
type: 'error',
});
} finally {
if (isMounted.current) {
setIsChoosingDownloadFolder(false);
}
}
});
}

/** The desktop app has no profile or team pages of its own, so these open the web app in the default browser */
Expand Down Expand Up @@ -272,19 +324,27 @@ export const Settings = () => {
description={
downloadPath ? (
<>
Files are saved to <span css={downloadPathCss}>{downloadPath}</span> without asking.
Files are saved to this folder without asking:
<span css={downloadPathCss}>
<BreakablePath path={downloadPath} />
</span>
</>
) : (
'You are asked where to save each download.'
)
}
>
<button className="slds-button slds-button_neutral" onClick={handleChooseDownloadFolder}>
<button
className="slds-button slds-button_neutral"
disabled={isChoosingDownloadFolder}
onClick={handleChooseDownloadFolder}
>
{downloadPath ? 'Change Folder…' : 'Choose Folder…'}
</button>
{downloadPath && (
<button
className="slds-button slds-button_neutral"
disabled={isChoosingDownloadFolder}
onClick={() => savePreferences({ fileDownload: { omitPrompt: false, downloadPath: '' } })}
>
Ask Every Time
Expand Down
157 changes: 157 additions & 0 deletions apps/jetstream-desktop/src/config/__tests__/windows-file-trust.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { isWindowsPolicyFileLocationTrusted, isWindowsPolicyFileOwnerTrusted, parseWindowsPolicyFileProbe } from '../windows-file-trust';

describe('windows-file-trust#isWindowsPolicyFileOwnerTrusted', () => {
/**
* The default %ProgramData% ACL lets a standard user create the policy folder and file, so only a
* file owned by SYSTEM (MDM/GPO) or the Administrators group counts as an administrator decision.
*/
it('trusts a file owned by SYSTEM or the built-in Administrators group', () => {
expect(isWindowsPolicyFileOwnerTrusted('S-1-5-18')).toBe(true);
expect(isWindowsPolicyFileOwnerTrusted('S-1-5-32-544')).toBe(true);
expect(isWindowsPolicyFileOwnerTrusted(' s-1-5-32-544\r\n')).toBe(true);
});

it('rejects a file owned by any user account', () => {
expect(isWindowsPolicyFileOwnerTrusted('S-1-5-21-3623811015-3361044348-30300820-1013')).toBe(false);
expect(isWindowsPolicyFileOwnerTrusted('BUILTIN\\Administrators')).toBe(false);
expect(isWindowsPolicyFileOwnerTrusted('')).toBe(false);
});
});

describe('windows-file-trust#isWindowsPolicyFileLocationTrusted', () => {
const ADMINISTRATORS = 'S-1-5-32-544';
const SYSTEM = 'S-1-5-18';
const USERS = 'S-1-5-32-545';
const EVERYONE = 'S-1-1-0';
const STANDARD_USER = 'S-1-5-21-1-2-3-1001';

// Access masks as `[int]$rule.FileSystemRights` reports them (winnt.h bits)
const FULL_CONTROL = 0x1f01ff;
const MODIFY = 0x301bf;
const READ_AND_EXECUTE = 0x200a9;
const WRITE = 0x116;
const SYNCHRONIZE = 0x100000;
const CREATE_FILES_AND_FOLDERS = 0x6; // FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY, the same bits as WriteData | AppendData
const DELETE_SUBDIRECTORIES_AND_FILES = 0x40;
const GENERIC_ALL = 0x10000000;
const GENERIC_WRITE = 0x40000000;
const GENERIC_READ_AND_EXECUTE = -1610612736; // GENERIC_READ | GENERIC_EXECUTE as a negative int32

/** What an administrator-created file and folder under %ProgramData% look like with the default inherited ACL */
const defaultFileRules = [
{ sid: SYSTEM, type: 'Allow', rights: FULL_CONTROL },
{ sid: ADMINISTRATORS, type: 'Allow', rights: FULL_CONTROL },
{ sid: USERS, type: 'Allow', rights: READ_AND_EXECUTE | SYNCHRONIZE },
];
const defaultDirRules = [
{ sid: SYSTEM, type: 'Allow', rights: FULL_CONTROL },
{ sid: ADMINISTRATORS, type: 'Allow', rights: FULL_CONTROL },
{ sid: USERS, type: 'Allow', rights: READ_AND_EXECUTE | SYNCHRONIZE },
// Every user may create files and folders under %ProgramData%; that cannot replace an existing administrator-owned file
{ sid: USERS, type: 'Allow', rights: CREATE_FILES_AND_FOLDERS },
// Inherit-only entries carry generic masks: CREATOR OWNER full control, Users generic read + execute
{ sid: 'S-1-3-0', type: 'Allow', rights: GENERIC_ALL },
{ sid: USERS, type: 'Allow', rights: GENERIC_READ_AND_EXECUTE },
];
const trusted = { fileOwner: ADMINISTRATORS, dirOwner: SYSTEM, fileRules: defaultFileRules, dirRules: defaultDirRules };

it('trusts an administrator-owned file in an administrator-owned folder with the default ACL', () => {
expect(isWindowsPolicyFileLocationTrusted(trusted)).toBe(true);
expect(isWindowsPolicyFileLocationTrusted({ ...trusted, fileOwner: SYSTEM, dirOwner: ADMINISTRATORS })).toBe(true);
});

it('rejects an administrator-owned file inside a folder a standard user created', () => {
// The user who owns the folder could swap the file between the ownership check and the read
expect(isWindowsPolicyFileLocationTrusted({ ...trusted, dirOwner: STANDARD_USER })).toBe(false);
});

it('rejects a user-owned file inside an administrator-owned folder', () => {
expect(isWindowsPolicyFileLocationTrusted({ ...trusted, fileOwner: STANDARD_USER })).toBe(false);
});

it('rejects a file whose ACL lets every user change it, whoever owns it', () => {
const withFileRule = (rule: { sid: string; type: string; rights: number }) => ({ ...trusted, fileRules: [...defaultFileRules, rule] });
expect(isWindowsPolicyFileLocationTrusted(withFileRule({ sid: USERS, type: 'Allow', rights: MODIFY | SYNCHRONIZE }))).toBe(false);
expect(
isWindowsPolicyFileLocationTrusted(withFileRule({ sid: EVERYONE, type: 'Allow', rights: WRITE | READ_AND_EXECUTE | SYNCHRONIZE })),
).toBe(false);
// The bits the enum would display as "CreateFiles" on a file are WriteData, so the alias cannot hide a write grant
expect(isWindowsPolicyFileLocationTrusted(withFileRule({ sid: USERS, type: 'Allow', rights: CREATE_FILES_AND_FOLDERS }))).toBe(false);
expect(isWindowsPolicyFileLocationTrusted(withFileRule({ sid: EVERYONE, type: 'Allow', rights: GENERIC_WRITE }))).toBe(false);
});

it('rejects a folder whose ACL lets every user remove or replace its files', () => {
const withDirRule = (rule: { sid: string; type: string; rights: number }) => ({ ...trusted, dirRules: [...defaultDirRules, rule] });
expect(isWindowsPolicyFileLocationTrusted(withDirRule({ sid: EVERYONE, type: 'Allow', rights: FULL_CONTROL }))).toBe(false);
expect(
isWindowsPolicyFileLocationTrusted(withDirRule({ sid: USERS, type: 'Allow', rights: DELETE_SUBDIRECTORIES_AND_FILES | SYNCHRONIZE })),
).toBe(false);
});

it('treats a grant to a logon class every user signs in through like a grant to Users', () => {
const CONSOLE_LOGON = 'S-1-2-1';
const NETWORK = 'S-1-5-2';
const REMOTE_INTERACTIVE_LOGON = 'S-1-5-14';
const THIS_ORGANIZATION = 'S-1-5-15';
for (const sid of [CONSOLE_LOGON, NETWORK, REMOTE_INTERACTIVE_LOGON, THIS_ORGANIZATION]) {
expect(
isWindowsPolicyFileLocationTrusted({
...trusted,
fileRules: [...defaultFileRules, { sid, type: 'Allow', rights: MODIFY | SYNCHRONIZE }],
}),
).toBe(false);
expect(
isWindowsPolicyFileLocationTrusted({
...trusted,
dirRules: [...defaultDirRules, { sid, type: 'Allow', rights: DELETE_SUBDIRECTORIES_AND_FILES }],
}),
).toBe(false);
}
});

it('rejects a file or folder that reports no access rules at all', () => {
// A NULL DACL grants everyone full access yet yields no rules
expect(isWindowsPolicyFileLocationTrusted({ ...trusted, fileRules: [] })).toBe(false);
expect(isWindowsPolicyFileLocationTrusted({ ...trusted, dirRules: [] })).toBe(false);
});

it('ignores deny entries and named accounts', () => {
expect(
isWindowsPolicyFileLocationTrusted({ ...trusted, fileRules: [...defaultFileRules, { sid: USERS, type: 'Deny', rights: MODIFY }] }),
).toBe(true);
expect(
isWindowsPolicyFileLocationTrusted({
...trusted,
fileRules: [...defaultFileRules, { sid: STANDARD_USER, type: 'Allow', rights: MODIFY }],
}),
).toBe(true);
});
});

describe('windows-file-trust#parseWindowsPolicyFileProbe', () => {
const rule = { sid: 'S-1-5-18', type: 'Allow', rights: 0x1f01ff };
const probe = { fileOwner: 'S-1-5-32-544', dirOwner: 'S-1-5-18', fileRules: [rule], dirRules: [rule], contents: '{}' };

it('accepts the shape the PowerShell probe emits', () => {
expect(parseWindowsPolicyFileProbe(probe)).toEqual(probe);
});

it('wraps a single rule that ConvertTo-Json unwrapped, and reads a missing rule list as empty', () => {
expect(parseWindowsPolicyFileProbe({ ...probe, fileRules: rule, dirRules: null })).toEqual({
...probe,
fileRules: [rule],
dirRules: [],
});
expect(parseWindowsPolicyFileProbe({ ...probe, dirRules: undefined })?.dirRules).toEqual([]);
});

it('rejects output whose owners, contents or access masks could not be read', () => {
expect(parseWindowsPolicyFileProbe({ ...probe, fileOwner: null })).toBeNull();
expect(parseWindowsPolicyFileProbe({ ...probe, dirOwner: undefined })).toBeNull();
expect(parseWindowsPolicyFileProbe({ ...probe, contents: 42 })).toBeNull();
expect(parseWindowsPolicyFileProbe({ ...probe, fileRules: [{ ...rule, rights: 'FullControl' }] })).toBeNull();
expect(parseWindowsPolicyFileProbe({ ...probe, dirRules: [{ type: 'Allow', rights: 1 }] })).toBeNull();
expect(parseWindowsPolicyFileProbe(null)).toBeNull();
});
});
31 changes: 29 additions & 2 deletions apps/jetstream-desktop/src/config/update-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { UpdatePolicy, UpdatePolicySource } from '@jetstream/desktop/types'
import logger from 'electron-log';
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import { isWindowsPolicyFileLocationTrusted, readWindowsPolicyFile } from './windows-file-trust';

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -135,9 +136,35 @@ export function parseManagedPolicyFile(contents: unknown): boolean | null {
return typeof value === 'undefined' ? null : toBoolean(value);
}

/**
* On Windows the file is only honored when an administrator put it there. The default ACL on
* %ProgramData% lets any standard user create a `Jetstream` subfolder and a `policy.json` inside it,
* which would otherwise let one unprivileged user of a shared machine switch updates off (or on) for
* everyone else - the documentation promises that users cannot mark their own install as managed.
* macOS and Linux need no check: the directories the file lives in are root-owned and not writable
* without administrator rights.
*/
async function readManagedPolicyFile(): Promise<boolean | null> {
const filePath = managedPolicyFilePath();
try {
return parseManagedPolicyFile(JSON.parse(await readFile(managedPolicyFilePath(), 'utf-8')));
if (process.platform !== 'win32') {
return parseManagedPolicyFile(JSON.parse(await readFile(filePath, 'utf-8')));
}
// A missing file is the normal case on an unmanaged machine; only spawn PowerShell when it exists.
// Nothing is read here - the file is untrusted until its location has been checked.
await stat(filePath);
const policyFile = await readWindowsPolicyFile(filePath);
if (!policyFile) {
logger.warn(`Ignoring update policy file ${filePath}: its contents or ownership could not be read`);
return null;
}
if (!isWindowsPolicyFileLocationTrusted(policyFile)) {
logger.warn(
`Ignoring update policy file ${filePath}: the file and its folder must both be owned by an administrator or SYSTEM and not grant every user write access (file owner ${policyFile.fileOwner}, folder owner ${policyFile.dirOwner})`,
);
return null;
}
return parseManagedPolicyFile(JSON.parse(policyFile.contents));
} catch {
return null;
}
Expand Down
Loading
Loading