Skip to content
17 changes: 17 additions & 0 deletions apps/studio/src/components/content-tab-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { SettingsMenuItem } from 'src/components/settings-site-menu';
import { useDeleteSite } from 'src/hooks/use-delete-site';
import { useGetWpVersion } from 'src/hooks/use-get-wp-version';
import { useSiteDetails } from 'src/hooks/use-site-details';
import { isLinux } from 'src/lib/app-globals';
import { getIpcApi } from 'src/lib/get-ipc-api';
import EditSiteDetails from 'src/modules/site-settings/edit-site-details';
import { useAppDispatch } from 'src/stores';
import {
certificateTrustApi,
useCheckCertificateTrustQuery,
useGetLinuxBrowserCertSupportStatusQuery,
} from 'src/stores/certificate-trust-api';

interface ContentTabSettingsProps {
Expand All @@ -42,6 +44,9 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
const dispatch = useAppDispatch();
const { __ } = useI18n();
const { data: isCertificateTrusted } = useCheckCertificateTrustQuery();
const { data: linuxBrowserCertStatus } = useGetLinuxBrowserCertSupportStatusQuery( undefined, {
skip: ! isLinux(),
} );
const username = selectedSite.adminUsername || 'admin';
// Empty strings account for legacy sites lacking a stored password.
const storedPassword = decodePassword( selectedSite.adminPassword ?? '' );
Expand Down Expand Up @@ -151,6 +156,18 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
<LearnHowLink docsLinksKey="docsSslInStudio" />
</div>
) }
{ ! isCertificateTrusted &&
selectedSite.enableHttps &&
linuxBrowserCertStatus?.firefoxDetected && (
<div className="mt-1 max-w-96" data-testid="trust-cert-firefox-notice">
<span className="text-frame-text-secondary mt-1">
{ __(
'Firefox uses its own certificate store. If Firefox doesn’t recognize the certificate, import it manually to avoid the security warning.'
) }
</span>{ ' ' }
<LearnHowLink docsLinksKey="docsSslLinuxFirefox" />
</div>
) }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we kept only the original notice we already have on trunk? Instead of adding this new one we could:

  • rely on the new docs section, e.g. "If you use Firefox on Linux" to communicate potential issue to the user
  • ensure the existing notice is displayed even in that very edge case where the user has Firefox installed, but haven't launched it yet.

I think that could streamline the user experience and also make the code simpler.

Copy link
Copy Markdown
Contributor Author

@gavande1 gavande1 May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Ivan, I want to push back a bit here. My biggest concern with falling back to just the existing notice is what it would take to also cover the "Firefox installed but never launched" case you mentioned. Are you comfortable with the notice being persistently visible in the settings tab? That's the part I'm most hesitant about.

The existing notice is gated on !isCertificateTrusted. On Linux that already factors in Firefox profile trust via areAllFirefoxProfilesTrustedLinux, but it's vacuously true when no profile exists — so to surface the notice in the installed-but-never-launched case, we'd have to gate it on something like isFirefoxInstalled, or make isRootCATrusted return false in that scenario. Either way, that signal doesn't auto-clear: a user who has Firefox installed (common on Ubuntu, where it ships by default) but never launches it would see the notice sitting in settings indefinitely.

To be upfront about the tradeoff: my current PR doesn't really solve that edge case either — the new Firefox notice is also gated on !isCertificateTrusted, so it stays silent until a profile actually exists. The difference is that when a profile does show up, the user gets Firefox-specific guidance instead of the generic message, and isFirefoxInstalledOnLinux is what lets us know to do that. I'd rather lean on docs for the never-launched case than make the notice permanent for everyone with Firefox preinstalled.

What I was going for overall is the closest analog to the Chrome trust-status check we already have: only surface platform-specific guidance when it's actionable. Happy to trim the supporting code if parts feel heavy — but I'd like to keep the conditional surfacing rather than collapsing it into a docs-only path. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Rahul! 👋🏼 I created this draft PR to illustrate what I meant: #3488. Basically, there would be just one notice that would be there only when it makes sense. So if the user did not start Firefox, the notice would not be rendered.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Ivan — your draft made this much easier to evaluate, and it landed exactly where you said it would: tested the trust-then-launch flow end-to-end on the Linux VM and the existing notice reappears on focus return as you described, then the second Trust click silently imports into the new profile.

Folded your commit into this PR as 1194d83f, so the new Firefox notice + its detection/IPC/RTK Query plumbing are all gone. Updating the PR description next to reflect the new shape. Happy to leave #3488 to you to close once you've had a chance to look.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the updates, Rahul! I will now re-review and test this PR.

</SettingsRow>
<SettingsRow label={ __( 'Local path' ) }>
<CopyTextButton
Expand Down
10 changes: 10 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {
isRootCATrusted,
trustRootCA,
} from 'src/lib/certificate-manager';
import { isFirefoxInstalledOnLinux } from 'src/lib/detect-linux-browsers';
import { download } from 'src/lib/download';
import { simplifyErrorForDisplay } from 'src/lib/error-formatting';
import { buildFeatureFlags } from 'src/lib/feature-flags';
Expand Down Expand Up @@ -1864,6 +1865,15 @@ export async function isCATrusted(): Promise< boolean > {
return isRootCATrusted();
}

export async function getLinuxBrowserCertSupportStatus(): Promise< {
firefoxDetected: boolean;
} > {
if ( process.platform !== 'linux' ) {
return { firefoxDetected: false };
}
return { firefoxDetected: isFirefoxInstalledOnLinux() };
}

export async function trustCertificate( event: IpcMainInvokeEvent ): Promise< void > {
const platform = process.platform;
if ( platform === 'win32' || platform === 'linux' ) {
Expand Down
17 changes: 13 additions & 4 deletions apps/studio/src/lib/certificate-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { promisify } from 'node:util';
import * as Sentry from '@sentry/electron/main';
import { CERT_UNTRUSTED_ROOT, SERVER_AUTH_OID } from '@studio/common/constants';
import {
areAllFirefoxProfilesTrustedLinux,
buildLinuxTrustInstallCommand,
importCAIntoFirefoxProfilesLinux,
importCAIntoUserNssDbsLinux,
isCAImportedInUserNssDbsLinux,
isCATrustedOnLinux,
Expand Down Expand Up @@ -52,11 +54,14 @@ export async function isRootCATrusted(): Promise< boolean > {
return false;
}
} else if ( process.platform === 'linux' ) {
// The CA is fully trusted on Linux only when it lives in both the system
// bundle (covers curl/openssl/Node) AND every NSS DB candidate (covers
// Chromium-family browsers, including Snap-Chromium's sandboxed DB).
// The CA is fully trusted on Linux only when it lives in the system
// bundle (curl/openssl/Node), every Chromium-family NSS DB (including
// Snap-Chromium's sandboxed DB), and every existing Firefox profile
// NSS DB. Firefox profiles that don't exist yet are vacuously trusted.
return (
( await isCATrustedOnLinux( CA_CERT_PATH ) ) && ( await isCAImportedInUserNssDbsLinux() )
( await isCATrustedOnLinux( CA_CERT_PATH ) ) &&
( await isCAImportedInUserNssDbsLinux() ) &&
( await areAllFirefoxProfilesTrustedLinux() )
);
}

Expand Down Expand Up @@ -118,6 +123,10 @@ export async function trustRootCA(): Promise< void > {
// Always run NSS imports — they're idempotent (-D before -A) and don't
// need sudo, so re-running covers the install-browser-after-trust case.
await importCAIntoUserNssDbsLinux( CA_CERT_PATH );
// Firefox keeps a per-profile NSS DB; the system + Chromium imports
// above don't reach it. Profiles that don't exist yet are skipped —
// the in-app notice covers that case until first Firefox launch.
await importCAIntoFirefoxProfilesLinux( CA_CERT_PATH );
} else {
console.error( 'Unsupported platform for automatic certificate trust:', platform );
}
Expand Down
18 changes: 18 additions & 0 deletions apps/studio/src/lib/detect-linux-browsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { findOnPath } from 'src/lib/find-on-path';

export function isFirefoxInstalledOnLinux( homeDir: string = os.homedir() ): boolean {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but from what I see, if we decide to drop that new UI notice, we could get rid of isFirefoxInstalledOnLinux and a few other code parts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1194d83fisFirefoxInstalledOnLinux, detect-linux-browsers.ts and its test, the IPC handler, the preload binding, and the RTK Query endpoint are all removed.

if ( findOnPath( 'firefox' ) !== null ) {
return true;
}
// Snap and Flatpak wrappers don't always land on $PATH for the current
// shell session, so fall back to the profile / data dirs they create.
const candidates = [
path.join( homeDir, '.mozilla', 'firefox' ),
path.join( homeDir, 'snap', 'firefox' ),
path.join( homeDir, '.var', 'app', 'org.mozilla.firefox' ),
];
return candidates.some( ( dir ) => fs.existsSync( dir ) );
}
3 changes: 3 additions & 0 deletions apps/studio/src/lib/get-localized-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ const DOCS_LINKS = {
docsSslInStudio: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/',
},
docsSslLinuxFirefox: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/#linux-firefox',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In relation to https://github.com/Automattic/studio/pull/3472/changes#r3240894626 we could do https://developer.wordpress.com/docs/developer-tools/studio/ssl-in-studio/#linux.

That would lead to a new Linux heading under the Certificate Trust by platform section.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by 1194d83fdocsSslLinuxFirefox was removed entirely. The existing notice keeps using docsSslInStudio, so the Linux subsection that lands on that page will be reachable from there.

},
docsMcp: {
en: 'https://developer.wordpress.com/docs/developer-tools/studio/mcp-on-studio/',
},
Expand Down
64 changes: 64 additions & 0 deletions apps/studio/src/lib/tests/detect-linux-browsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @vitest-environment node
*/
import fs from 'node:fs';
import { vi } from 'vitest';
import { findOnPath } from 'src/lib/find-on-path';
import { isFirefoxInstalledOnLinux } from '../detect-linux-browsers';

vi.mock( 'node:fs', () => ( {
default: { existsSync: vi.fn() },
existsSync: vi.fn(),
} ) );

// Match the specifier used by the implementation so vi.mock targets the
// same module identity regardless of how the resolver normalises paths.
vi.mock( 'src/lib/find-on-path', () => ( {
findOnPath: vi.fn(),
} ) );

const HOME = '/home/tester';

function mockExistingPaths( paths: string[] ) {
// Normalise separators so the test passes on Windows CI, where
// path.join in the implementation produces backslashes.
const normalize = ( p: string ) => p.replace( /\\/g, '/' );
const expected = paths.map( normalize );
vi.mocked( fs.existsSync ).mockImplementation( ( candidate: fs.PathLike ) =>
expected.includes( normalize( String( candidate ) ) )
);
}

describe( 'isFirefoxInstalledOnLinux', () => {
beforeEach( () => {
vi.resetAllMocks();
mockExistingPaths( [] );
vi.mocked( findOnPath ).mockReturnValue( null );
} );

it( 'returns true when firefox is on PATH', () => {
vi.mocked( findOnPath ).mockImplementation( ( cmd ) =>
cmd === 'firefox' ? '/usr/bin/firefox' : null
);
expect( isFirefoxInstalledOnLinux( HOME ) ).toBe( true );
} );

it( 'returns true when ~/.mozilla/firefox exists', () => {
mockExistingPaths( [ `${ HOME }/.mozilla/firefox` ] );
expect( isFirefoxInstalledOnLinux( HOME ) ).toBe( true );
} );

it( 'returns true when Snap Firefox data dir exists', () => {
mockExistingPaths( [ `${ HOME }/snap/firefox` ] );
expect( isFirefoxInstalledOnLinux( HOME ) ).toBe( true );
} );

it( 'returns true when Flatpak Firefox dir exists', () => {
mockExistingPaths( [ `${ HOME }/.var/app/org.mozilla.firefox` ] );
expect( isFirefoxInstalledOnLinux( HOME ) ).toBe( true );
} );

it( 'returns false when neither PATH nor profile/data dirs exist', () => {
expect( isFirefoxInstalledOnLinux( HOME ) ).toBe( false );
} );
} );
1 change: 1 addition & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const api: IpcApi = {
showOpenFolderDialog: ( title, defaultDialogPath ) =>
ipcRendererInvoke( 'showOpenFolderDialog', title, defaultDialogPath ),
isCATrusted: () => ipcRenderer.invoke( 'isCATrusted' ),
getLinuxBrowserCertSupportStatus: () => ipcRenderer.invoke( 'getLinuxBrowserCertSupportStatus' ),
trustCertificate: () => ipcRenderer.invoke( 'trustCertificate' ),
showSaveAsDialog: ( options ) => ipcRendererInvoke( 'showSaveAsDialog', options ),
saveUserLocale: ( locale ) => ipcRendererInvoke( 'saveUserLocale', locale ),
Expand Down
15 changes: 14 additions & 1 deletion apps/studio/src/stores/certificate-trust-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,20 @@ export const certificateTrustApi = createApi( {
},
providesTags: [ 'CertificateTrust' ],
} ),
getLinuxBrowserCertSupportStatus: builder.query< { firefoxDetected: boolean }, void >( {
queryFn: async () => {
try {
const status = await getIpcApi().getLinuxBrowserCertSupportStatus();
return { data: status };
} catch ( error ) {
console.error( 'Failed to get Linux browser cert support status:', error );
return { data: { firefoxDetected: false } };
}
},
providesTags: [ 'CertificateTrust' ],
} ),
} ),
} );

export const { useCheckCertificateTrustQuery } = certificateTrustApi;
export const { useCheckCertificateTrustQuery, useGetLinuxBrowserCertSupportStatusQuery } =
certificateTrustApi;
88 changes: 87 additions & 1 deletion tools/common/lib/linux-trust-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
import { existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
Expand Down Expand Up @@ -83,3 +83,89 @@ export async function importCAIntoUserNssDbsLinux( caPath: string ): Promise< vo
}
}
}

// Firefox keeps a private NSS DB per profile (cert9.db). The auto-trust
// flow has to walk every install root — apt installs use ~/.mozilla,
// Snap and Flatpak each have their own profile root.
function getFirefoxProfileRootsLinux( homeDir: string ): string[] {
return [
path.join( homeDir, '.mozilla', 'firefox' ),
path.join( homeDir, 'snap', 'firefox', 'common', '.mozilla', 'firefox' ),
path.join( homeDir, '.var', 'app', 'org.mozilla.firefox', '.mozilla', 'firefox' ),
];
}

// Returns profile dirs that already have a Mozilla NSS DB (cert9.db).
// Profiles without cert9.db are skipped: Firefox creates the DB on first
// launch, so there's nothing to import into until the user has opened the
// browser at least once.
export function getLinuxFirefoxProfileDbDirs( homeDir: string = os.homedir() ): string[] {
const dirs: string[] = [];
for ( const root of getFirefoxProfileRootsLinux( homeDir ) ) {
if ( ! existsSync( root ) ) continue;
let entries: string[];
try {
entries = readdirSync( root );
} catch {
continue;
}
for ( const entry of entries ) {
// Firefox profile dirs end with `.default`, `.default-release`,
// `.default-esr`, etc. (per profiles.ini conventions).
if ( ! /\.default(?:-[^/]+)?$/.test( entry ) ) continue;
const profileDir = path.join( root, entry );
if ( existsSync( path.join( profileDir, 'cert9.db' ) ) ) {
dirs.push( profileDir );
}
}
}
return dirs;
}

// Vacuously true when no Firefox profile exists yet (browser installed but
// never opened): there is nothing to import into, so the system+Chromium
// trust state alone is enough to consider the trust flow complete.
export async function areAllFirefoxProfilesTrustedLinux(
homeDir: string = os.homedir()
): Promise< boolean > {
for ( const db of getLinuxFirefoxProfileDbDirs( homeDir ) ) {
try {
await execFilePromise( 'certutil', [ '-d', `sql:${ db }`, '-L', '-n', LINUX_NSS_NICKNAME ] );
} catch {
return false;
}
}
return true;
}

// Best-effort: same contract as importCAIntoUserNssDbsLinux. Profiles with
// a locked DB (Firefox is running) or missing certutil log a warning and
// the user falls back to the in-app notice + docs.
export async function importCAIntoFirefoxProfilesLinux( caPath: string ): Promise< void > {
for ( const db of getLinuxFirefoxProfileDbDirs() ) {
try {
await execFilePromise( 'certutil', [
'-d',
`sql:${ db }`,
'-D',
'-n',
LINUX_NSS_NICKNAME,
] ).catch( () => undefined );
await execFilePromise( 'certutil', [
'-d',
`sql:${ db }`,
'-A',
'-t',
'C,,',
'-n',
LINUX_NSS_NICKNAME,
'-i',
caPath,
] );
console.log( `Imported Studio CA into Firefox profile: ${ db }` );
} catch ( error ) {
const message = error instanceof Error ? error.message : String( error );
console.warn( `Could not import Studio CA into Firefox profile ${ db }: ${ message }` );
}
}
}
Loading