fix(electron): gate Jira IPC behind a one-shot capability (#9008)

* docs(plugins): add microsoft 365 calendar provider plan

* fix(jira): gate Electron requests behind one-shot capability

Claim privileged Jira IPC before plugin startup and return responses through invoke instead of a broadcast event. Keep arbitrary HTTP(S) Jira hosts supported while rejecting redirects and bounding request resources.

* fix(jira): enforce Electron request capability

Bind privileged Jira IPC to a main-issued renderer-document token and strip raw Electron events from renderer callbacks. Scope image authentication by origin, base path, and resource type while preserving safe redirects and legacy configurations.

* fix(electron): handle payload-only IPC lifecycle

Clear Jira image authentication before replacement and when a new renderer document claims the capability. Parse before-close IDs from payload-only events so pending sync and finish-day hooks can complete.

* fix(electron): address Jira IPC capability review findings

- electron.effects: read ANY_FILE_DOWNLOADED payload at [0] after the
  payload-only IPC refactor (was [1], now undefined -> TypeError on every
  download); guard against a malformed payload
- jira-capability: rotate the token on re-register so a renderer reload
  that reuses the WebFrameMain object is not permanently locked out of
  Jira; invalidates any stale token
- document that the one-shot consumption order, not the bypassable
  main-frame check, is the real capability boundary
- jira-electron-bridge: skip the no-op clearImgHeaders IPC round-trip
  when image auth was never set up (non-Jira detail-panel open/close)
- jira-api: route a synchronous _toElectronRequestInit throw through
  _handleResponse instead of leaking a dangling request-log entry

* test(electron): cover ANY_FILE_DOWNLOADED payload parsing

Extract parseDownloadedFilePayload from ElectronEffects and add a
regression spec pinning the payload-only shape ([file], not [event,
file]) that caused a TypeError on every download. Hardened against
non-array input surfaced by the new test.

* fix(electron): restore Node ambient globals for frontend build
This commit is contained in:
Johannes Millan 2026-07-14 21:02:27 +02:00 committed by GitHub
parent 75d3cd3c0a
commit 95d3b212bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1459 additions and 313 deletions

View file

@ -1,11 +1,17 @@
import { IpcRendererEvent } from 'electron';
// This file is pulled into the frontend TS program (via src/app/core/window-ea.d.ts).
// That program sets `types: []`, so it has no ambient Node globals of its own and
// relied on the now-removed `import { IpcRendererEvent } from 'electron'` here to
// transitively supply them. Several frontend/shared modules still probe Node globals
// guarded at runtime (get-dist-channel's `process`/`NodeJS`, generate-client-id's
// `process`, create-task-placeholder's `NodeJS.Timeout`, user-profile's `require`),
// so re-expose them explicitly instead of by accident.
/// <reference types="node" />
import {
GlobalConfigState,
TakeABreakConfig,
TaskWidgetConfig,
} from '../src/app/features/config/global-config.model';
import { KeyboardConfig } from './shared-with-frontend/keyboard-config.model';
import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
import { AppDataCompleteLegacy } from '../src/app/imex/sync/sync.model';
import { Task } from '../src/app/features/tasks/task.model';
import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model';
@ -16,12 +22,10 @@ import {
LocalRestApiResponsePayload,
} from './shared-with-frontend/local-rest-api.model';
import { ElectronDistChannel } from './shared-with-frontend/get-dist-channel';
import { JiraElectronApi } from './shared-with-frontend/jira-request.model';
export interface ElectronAPI {
on(
channel: string,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
): void;
on(channel: string, listener: (...args: unknown[]) => void): void;
// SYNC
// ----
@ -220,16 +224,6 @@ export interface ElectronAPI {
showFullScreenBlocker(args: { msg?: string; takeABreakCfg: TakeABreakConfig }): void;
// TODO use invoke instead
makeJiraRequest(args: {
requestId: string;
url: string;
requestInit: RequestInit;
jiraCfg: JiraCfg;
}): void;
jiraSetupImgHeaders(args: { jiraCfg: JiraCfg }): void;
backupAppData(args: {
data: AppDataCompleteLegacy | AppDataComplete;
maxBackupFiles?: number | null;
@ -250,6 +244,8 @@ export interface ElectronAPI {
onSwitchTask(listener: (taskId: string) => void): void;
consumeJiraApi(): JiraElectronApi | null;
consumePluginNodeExecutionApi(): PluginNodeExecutionElectronApi | null;
// Plugin OAuth

View file

@ -1,14 +1,41 @@
import { ipcMain } from 'electron';
import { IPC } from '../shared-with-frontend/ipc-events.const';
import { JiraCfg } from '../../src/app/features/issue/providers/jira/jira.model';
import { sendJiraRequest, setupRequestHeadersForImages } from '../jira';
import { executeJiraRequest } from '../jira';
import { JiraCapabilityRegistry } from '../jira-capability';
import {
clearRequestHeadersForImages,
setupRequestHeadersForImages,
} from '../jira-image-auth';
const capabilityRegistry = new JiraCapabilityRegistry();
export const initJiraIpc = (): void => {
ipcMain.on(IPC.JIRA_SETUP_IMG_HEADERS, (ev, { jiraCfg }: { jiraCfg: JiraCfg }) => {
setupRequestHeadersForImages(jiraCfg);
ipcMain.handle(IPC.JIRA_REGISTER_CAPABILITY, (event) => {
// NOTE: this main-frame check is a secondary guard, not the real boundary:
// same-origin plugin iframes can reach window.top.ea, so their IPC also
// arrives with senderFrame === mainFrame. The actual protection is that
// trusted startup code consumes the one-shot capability before any plugin
// code runs (see JiraElectronBridgeService.initialize / StartupService).
if (event.senderFrame !== event.sender.mainFrame) {
return null;
}
const token = capabilityRegistry.register(event.senderFrame);
// A fresh document just claimed the capability — drop any stale image auth
// left over from the previous renderer document.
clearRequestHeadersForImages();
return token;
});
ipcMain.on(IPC.JIRA_MAKE_REQUEST_EVENT, (ev, request) => {
sendJiraRequest(request);
ipcMain.handle(IPC.JIRA_SETUP_IMG_HEADERS, (event, envelope: unknown) => {
setupRequestHeadersForImages(capabilityRegistry.unwrap(event.senderFrame, envelope));
});
ipcMain.handle(IPC.JIRA_CLEAR_IMG_HEADERS, (event, envelope: unknown) => {
capabilityRegistry.unwrap(event.senderFrame, envelope);
clearRequestHeadersForImages();
});
ipcMain.handle(IPC.JIRA_MAKE_REQUEST_EVENT, (event, envelope: unknown) =>
executeJiraRequest(capabilityRegistry.unwrap(event.senderFrame, envelope)),
);
};

View file

@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
require('ts-node/register/transpile-only');
const { JiraCapabilityRegistry } = require(
path.resolve(__dirname, 'jira-capability.ts'),
);
test('rotates the Jira capability when a renderer document re-registers', () => {
let counter = 0;
const registry = new JiraCapabilityRegistry(() => `test-token-${(counter += 1)}`);
const frame = {};
const first = registry.register(frame);
assert.equal(first, 'test-token-1');
// A reload re-registers the same frame object: a fresh token is issued and
// the stale one is invalidated, so the reloaded document is never locked out.
const second = registry.register(frame);
assert.equal(second, 'test-token-2');
assert.equal(registry.isAuthorized(frame, first), false);
assert.equal(registry.isAuthorized(frame, second), true);
});
test('only authorizes the issued token from the same renderer document', () => {
const registry = new JiraCapabilityRegistry(() => 'test-token');
const registeredFrame = {};
const otherFrame = {};
registry.register(registeredFrame);
assert.equal(registry.isAuthorized(registeredFrame, 'test-token'), true);
assert.equal(registry.isAuthorized(registeredFrame, 'wrong-token'), false);
assert.equal(registry.isAuthorized(otherFrame, 'test-token'), false);
assert.equal(registry.isAuthorized(registeredFrame, null), false);
});
test('unwraps only an authorized Jira capability envelope', () => {
const registry = new JiraCapabilityRegistry(() => 'test-token');
const frame = {};
registry.register(frame);
const payload = { requestId: 'request-1' };
assert.equal(
registry.unwrap(frame, {
capabilityToken: 'test-token',
payload,
}),
payload,
);
assert.throws(
() => registry.unwrap(frame, { capabilityToken: 'wrong-token', payload }),
/unauthorized/i,
);
assert.throws(() => registry.unwrap(frame, payload), /unauthorized/i);
});

View file

@ -0,0 +1,44 @@
import { randomBytes } from 'node:crypto';
import { JiraCapabilityEnvelope } from './shared-with-frontend/jira-request.model';
const TOKEN_BYTES = 32;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export class JiraCapabilityRegistry {
private readonly _tokens = new WeakMap<object, string>();
constructor(
private readonly _createToken: () => string = () =>
randomBytes(TOKEN_BYTES).toString('base64url'),
) {}
register(frame: object): string {
// Always issue a fresh token, even when this frame object already has one.
// A renderer reload re-runs the preload and re-registers; if Electron hands
// back the same WebFrameMain object, returning null would leave the new
// document permanently without a capability (Jira broken until a full app
// restart). Rotating the token also invalidates any stale token still held
// by the previous document.
const token = this._createToken();
this._tokens.set(frame, token);
return token;
}
isAuthorized(frame: object, token: unknown): token is string {
return typeof token === 'string' && this._tokens.get(frame) === token;
}
unwrap<T>(frame: object, envelope: unknown): T {
if (
!isRecord(envelope) ||
!this.isAuthorized(frame, envelope.capabilityToken) ||
!Object.prototype.hasOwnProperty.call(envelope, 'payload')
) {
throw new Error('Unauthorized Jira IPC request');
}
return (envelope as unknown as JiraCapabilityEnvelope<T>).payload;
}
}

100
electron/jira-image-auth.ts Normal file
View file

@ -0,0 +1,100 @@
import { JiraImageAuthConfig } from './shared-with-frontend/jira-request.model';
interface JiraImageAuthState {
origin: string;
basePath: string;
authorization: string;
}
let currentAuth: JiraImageAuthState | null = null;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const isNullableString = (value: unknown): value is string | null =>
value === null || typeof value === 'string';
const isOptionalNullableString = (value: unknown): value is string | null | undefined =>
value === undefined || isNullableString(value);
const parseImageAuthConfig = (config: unknown): JiraImageAuthConfig => {
if (!isRecord(config)) {
throw new Error('Invalid Jira image authentication config');
}
const { host, userName, password, usePAT } = config;
if (
typeof host !== 'string' ||
host.trim().length === 0 ||
!isNullableString(userName) ||
!isOptionalNullableString(password) ||
(usePAT !== undefined && typeof usePAT !== 'boolean')
) {
throw new Error('Invalid Jira image authentication config');
}
return {
host,
userName,
password,
usePAT: usePAT === true,
};
};
// TODO simplify and do encoding in frontend service
export const setupRequestHeadersForImages = (rawConfig: unknown): void => {
currentAuth = null;
const config = parseImageAuthConfig(rawConfig);
const parsedUrl = new URL(
/^[a-z][a-z\d+.-]*:\/\//i.test(config.host as string)
? (config.host as string)
: `https://${config.host}`,
);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Jira URL must use HTTP or HTTPS');
}
const password = config.password || '';
const encoded = Buffer.from(`${config.userName || ''}:${password}`).toString('base64');
const trimmedPath = parsedUrl.pathname.replace(/\/+$/, '');
currentAuth = {
origin: parsedUrl.origin,
basePath: trimmedPath || '/',
authorization: config.usePAT ? `Bearer ${password}` : `Basic ${encoded}`,
};
};
export const applyJiraImageAuth = (
rawUrl: string,
requestHeaders: Record<string, string>,
resourceType: string,
): void => {
if (!currentAuth || resourceType !== 'image') {
return;
}
let requestUrl: URL;
try {
requestUrl = new URL(rawUrl);
} catch {
return;
}
const isInBasePath =
currentAuth.basePath === '/' ||
requestUrl.pathname === currentAuth.basePath ||
requestUrl.pathname.startsWith(`${currentAuth.basePath}/`);
if (requestUrl.origin !== currentAuth.origin || !isInBasePath) {
return;
}
for (const headerName of Object.keys(requestHeaders)) {
if (headerName.toLowerCase() === 'authorization') {
delete requestHeaders[headerName];
}
}
requestHeaders.authorization = currentAuth.authorization;
};
export const clearRequestHeadersForImages = (): void => {
currentAuth = null;
};

107
electron/jira-ipc.test.cjs Normal file
View file

@ -0,0 +1,107 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const Module = require('node:module');
require('ts-node/register/transpile-only');
const handlers = new Map();
const jiraImageAuth = require(path.resolve(__dirname, 'jira-image-auth.ts'));
const originalModuleLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return {
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler),
on: () => undefined,
},
};
}
if (request === '../jira-image-auth') {
return jiraImageAuth;
}
return originalModuleLoad.call(this, request, parent, isMain);
};
const { initJiraIpc } = require(path.resolve(__dirname, 'ipc-handlers/jira.ts'));
const { IPC } = require(
path.resolve(__dirname, 'shared-with-frontend/ipc-events.const.ts'),
);
const { applyJiraImageAuth, setupRequestHeadersForImages } = jiraImageAuth;
Module._load = originalModuleLoad;
initJiraIpc();
test('Jira IPC issues a capability only to the main renderer frame', () => {
const mainFrame = {};
const event = { senderFrame: mainFrame, sender: { mainFrame } };
const register = handlers.get(IPC.JIRA_REGISTER_CAPABILITY);
const token = register(event);
assert.equal(typeof token, 'string');
// Re-registering (e.g. after a renderer reload) rotates the token instead of
// locking the frame out.
const rotated = register(event);
assert.equal(typeof rotated, 'string');
assert.notEqual(rotated, token);
const subFrame = {};
assert.equal(register({ senderFrame: subFrame, sender: { mainFrame } }), null);
});
test('Jira IPC rejects a request without the document capability', () => {
const mainFrame = {};
const event = { senderFrame: mainFrame, sender: { mainFrame } };
const register = handlers.get(IPC.JIRA_REGISTER_CAPABILITY);
const makeRequest = handlers.get(IPC.JIRA_MAKE_REQUEST_EVENT);
register(event);
assert.throws(
() =>
makeRequest(event, {
capabilityToken: 'forged-token',
payload: { requestId: 'request-1' },
}),
/unauthorized/i,
);
});
test('Jira IPC unwraps an authorized request before validation', async () => {
const mainFrame = {};
const event = { senderFrame: mainFrame, sender: { mainFrame } };
const register = handlers.get(IPC.JIRA_REGISTER_CAPABILITY);
const makeRequest = handlers.get(IPC.JIRA_MAKE_REQUEST_EVENT);
const token = register(event);
const result = await makeRequest(event, {
capabilityToken: token,
payload: { requestId: 'request-1' },
});
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Invalid Jira URL' },
});
});
test('issuing a capability to a new renderer document revokes stale image auth', () => {
setupRequestHeadersForImages({
host: 'https://jira.example.com/jira',
userName: 'user',
password: 'pass',
usePAT: false,
});
const mainFrame = {};
const event = { senderFrame: mainFrame, sender: { mainFrame } };
const register = handlers.get(IPC.JIRA_REGISTER_CAPABILITY);
assert.equal(typeof register(event), 'string');
const requestHeaders = {};
applyJiraImageAuth(
'https://jira.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.deepEqual(requestHeaders, {});
});

420
electron/jira.test.cjs Normal file
View file

@ -0,0 +1,420 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
require('ts-node/register/transpile-only');
const {
executeJiraRequest,
} = require(
path.resolve(__dirname, 'jira.ts'),
);
const {
applyJiraImageAuth,
clearRequestHeadersForImages,
setupRequestHeadersForImages,
} = require(path.resolve(__dirname, 'jira-image-auth.ts'));
const makeRequest = (overrides = {}) => ({
requestId: 'request-1',
url: 'https://jira.example.com/rest/api/latest/myself',
requestInit: {
method: 'GET',
headers: {
authorization: 'Basic secret',
'Content-Type': 'application/json',
},
},
allowSelfSignedCertificate: false,
...overrides,
});
test('allows Jira hosted on localhost and applies non-overridable fetch limits', async () => {
let fetchedUrl;
let fetchedInit;
const fetchStub = async (url, init) => {
fetchedUrl = url;
fetchedInit = init;
return {
ok: true,
text: async () => '{"ok":true}',
};
};
const result = await executeJiraRequest(
makeRequest({
url: 'http://127.0.0.1:8080/jira/rest/api/latest/myself',
requestInit: {
method: 'POST',
headers: { authorization: 'Bearer secret' },
body: '{"query":"test"}',
redirect: 'follow',
timeout: 0,
size: 0,
},
}),
fetchStub,
() => undefined,
);
assert.equal(fetchedUrl, 'http://127.0.0.1:8080/jira/rest/api/latest/myself');
assert.deepEqual(
{
method: fetchedInit.method,
headers: fetchedInit.headers,
body: fetchedInit.body,
redirect: fetchedInit.redirect,
timeout: fetchedInit.timeout,
size: fetchedInit.size,
},
{
method: 'POST',
headers: { authorization: 'Bearer secret' },
body: '{"query":"test"}',
redirect: 'manual',
timeout: 20_000,
size: 25 * 1024 * 1024,
},
);
assert.deepEqual(result, {
requestId: 'request-1',
response: { ok: true },
});
});
test('rejects non-HTTP Jira URLs before fetch', async () => {
let fetchCalled = false;
const result = await executeJiraRequest(
makeRequest({ url: 'file:///etc/passwd' }),
async () => {
fetchCalled = true;
throw new Error('must not run');
},
() => undefined,
);
assert.equal(fetchCalled, false);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Jira URL must use HTTP or HTTPS' },
});
});
test('rejects methods outside the Jira API contract', async () => {
let fetchCalled = false;
const result = await executeJiraRequest(
makeRequest({ requestInit: { method: 'DELETE', headers: {} } }),
async () => {
fetchCalled = true;
throw new Error('must not run');
},
() => undefined,
);
assert.equal(fetchCalled, false);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Invalid Jira request method' },
});
});
test('returns an HTTP error without copying the remote response body', async () => {
let responseBodyDestroyed = false;
const result = await executeJiraRequest(
makeRequest(),
async () => ({
ok: false,
status: 401,
statusText: 'Unauthorized',
body: { destroy: () => (responseBodyDestroyed = true) },
text: async () => 'Access denied',
}),
() => undefined,
);
assert.deepEqual(result, {
requestId: 'request-1',
error: {
message: 'HTTP 401',
status: 401,
},
});
assert.equal(responseBodyDestroyed, true);
assert.equal('stack' in result.error, false);
});
test('treats a missing legacy certificate setting as false', async () => {
let allowSelfSignedCertificate;
const request = makeRequest();
delete request.allowSelfSignedCertificate;
const result = await executeJiraRequest(
request,
async () => ({
ok: true,
text: async () => '{"ok":true}',
}),
(_url, allowSelfSigned) => {
allowSelfSignedCertificate = allowSelfSigned;
return undefined;
},
);
assert.equal(allowSelfSignedCertificate, false);
assert.deepEqual(result.response, { ok: true });
});
test('follows a same-origin Jira redirect', async () => {
const fetchedUrls = [];
let redirectBodyDestroyed = false;
const result = await executeJiraRequest(
makeRequest(),
async (url) => {
fetchedUrls.push(url);
if (fetchedUrls.length === 1) {
return {
ok: false,
status: 302,
statusText: 'Found',
body: { destroy: () => (redirectBodyDestroyed = true) },
headers: { get: (name) => (name === 'location' ? '/jira/login' : null) },
text: async () => '',
};
}
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => '{"redirected":true}',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'https://jira.example.com/rest/api/latest/myself',
'https://jira.example.com/jira/login',
]);
assert.deepEqual(result.response, { redirected: true });
assert.equal(redirectBodyDestroyed, true);
});
test('allows an HTTP to HTTPS redirect on the configured Jira hostname', async () => {
const fetchedUrls = [];
const result = await executeJiraRequest(
makeRequest({ url: 'http://jira.example.com:8080/rest/api/latest/myself' }),
async (url) => {
fetchedUrls.push(url);
if (fetchedUrls.length === 1) {
return {
ok: false,
status: 308,
statusText: 'Permanent Redirect',
headers: {
get: (name) =>
name === 'location'
? 'https://jira.example.com:8443/rest/api/latest/myself'
: null,
},
text: async () => '',
};
}
return {
ok: true,
status: 200,
headers: { get: () => null },
text: async () => '{"upgraded":true}',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'http://jira.example.com:8080/rest/api/latest/myself',
'https://jira.example.com:8443/rest/api/latest/myself',
]);
assert.deepEqual(result.response, { upgraded: true });
});
test('rejects redirects to a different hostname', async () => {
const fetchedUrls = [];
const result = await executeJiraRequest(
makeRequest(),
async (url) => {
fetchedUrls.push(url);
return {
ok: false,
status: 302,
statusText: 'Found',
headers: {
get: (name) =>
name === 'location' ? 'https://internal.example.test/secret' : null,
},
text: async () => '',
};
},
() => undefined,
);
assert.deepEqual(fetchedUrls, [
'https://jira.example.com/rest/api/latest/myself',
]);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Unsafe Jira redirect blocked' },
});
});
test('stops after five safe Jira redirects', async () => {
let fetchCalls = 0;
const result = await executeJiraRequest(
makeRequest(),
async () => {
fetchCalls += 1;
return {
ok: false,
status: 302,
statusText: 'Found',
headers: { get: () => `/redirect-${fetchCalls}` },
text: async () => '',
};
},
() => undefined,
);
assert.equal(fetchCalls, 6);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'Too many Jira redirects' },
});
});
test('does not expose thrown error details beyond the message', async () => {
const thrown = Object.assign(new Error('network failed'), {
secret: 'do not return',
});
const result = await executeJiraRequest(
makeRequest(),
async () => {
throw thrown;
},
() => undefined,
);
assert.deepEqual(result, {
requestId: 'request-1',
error: { message: 'network failed' },
});
});
test('scopes image authentication to a custom Jira origin with port and base path', () => {
setupRequestHeadersForImages({
host: 'http://localhost:8080/jira',
userName: 'user',
password: 'pass',
usePAT: false,
});
const matchingHeaders = { accept: 'image/png' };
applyJiraImageAuth(
'http://localhost:8080/jira/secure/attachment/1/image.png',
matchingHeaders,
'image',
);
assert.deepEqual(matchingHeaders, {
accept: 'image/png',
authorization: `Basic ${Buffer.from('user:pass').toString('base64')}`,
});
const outsidePathHeaders = {};
applyJiraImageAuth(
'http://localhost:8080/other/image.png',
outsidePathHeaders,
'image',
);
assert.deepEqual(outsidePathHeaders, {});
const prefixCollisionHeaders = {};
applyJiraImageAuth(
'http://localhost:8080/jira-evil/image.png',
prefixCollisionHeaders,
'image',
);
assert.deepEqual(prefixCollisionHeaders, {});
const xhrHeaders = {};
applyJiraImageAuth('http://localhost:8080/jira/rest/api/latest/issue/1', xhrHeaders, 'xhr');
assert.deepEqual(xhrHeaders, {});
});
test('treats a missing legacy PAT setting as basic authentication', () => {
setupRequestHeadersForImages({
host: 'https://jira.example.com/jira/',
userName: 'user',
password: 'pass',
});
const requestHeaders = {};
applyJiraImageAuth(
'https://jira.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.equal(
requestHeaders.authorization,
`Basic ${Buffer.from('user:pass').toString('base64')}`,
);
});
test('clears Jira image authentication when it is no longer needed', () => {
clearRequestHeadersForImages();
const requestHeaders = {};
applyJiraImageAuth(
'https://jira.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.deepEqual(requestHeaders, {});
});
test('clears previous image authentication when replacement config is invalid', () => {
setupRequestHeadersForImages({
host: 'https://jira-a.example.com/jira',
userName: 'user',
password: 'pass',
usePAT: false,
});
assert.throws(
() =>
setupRequestHeadersForImages({
host: null,
userName: 'other-user',
password: 'other-pass',
usePAT: false,
}),
/Invalid Jira image authentication config/,
);
const requestHeaders = {};
applyJiraImageAuth(
'https://jira-a.example.com/jira/image.png',
requestHeaders,
'image',
);
assert.deepEqual(requestHeaders, {});
});
test('rejects a non-HTTP Jira image authentication origin', () => {
assert.throws(
() =>
setupRequestHeadersForImages({
host: 'file:///tmp/jira',
userName: 'user',
password: 'pass',
usePAT: false,
}),
/HTTP or HTTPS/,
);
});

View file

@ -1,131 +1,230 @@
import { getWin } from './main-window';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { session } from 'electron';
import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
import fetch, { RequestInit } from 'node-fetch';
import { error, log } from 'electron-log/main';
import fetch, { RequestInit, Response } from 'node-fetch';
import { createProxyAwareAgent } from './proxy-agent';
import {
JiraElectronRequest,
JiraElectronResponse,
JIRA_MAIN_REQUEST_TIMEOUT_MS,
JIRA_MAX_RESPONSE_BYTES,
} from './shared-with-frontend/jira-request.model';
export const sendJiraRequest = ({
requestId,
requestInit,
url,
jiraCfg,
}: {
requestId: string;
requestInit: RequestInit;
url: string;
jiraCfg: JiraCfg;
}): void => {
const mainWin = getWin();
const agent = createProxyAwareAgent(url, jiraCfg?.isAllowSelfSignedCertificate);
const MAX_REQUEST_ID_LENGTH = 256;
const MAX_URL_LENGTH = 16 * 1024;
const MAX_HEADER_BYTES = 64 * 1024;
const MAX_BODY_BYTES = 5 * 1024 * 1024;
const MAX_ERROR_MESSAGE_LENGTH = 2_000;
const MAX_REDIRECTS = 5;
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
fetch(url, {
...requestInit,
...(agent ? { agent } : {}),
} as RequestInit)
.then(async (response) => {
// log('JIRA_RAW_RESPONSE', response);
if (!response.ok) {
error('Jira Error Error Response ELECTRON: ', response);
try {
log(JSON.stringify(response));
} catch (e) {}
type FetchImplementation = (url: string, init: RequestInit) => Promise<Response>;
type AgentFactory = typeof createProxyAwareAgent;
let errText;
try {
errText = await response.text();
} catch (e2) {
throw Error(response.statusText);
}
throw Error(errText || response.statusText);
}
return response;
})
.then((res) => res.text())
.then((text) => {
try {
return text ? JSON.parse(text) : {};
} catch (e) {
console.error('Error: Cannot parse json');
console.log('Error: text response', text);
// throw new Error(e);
return text;
}
})
.then((response) => {
mainWin.webContents.send(IPC.JIRA_CB_EVENT, {
response,
requestId,
});
})
.catch((err: unknown) => {
mainWin.webContents.send(IPC.JIRA_CB_EVENT, {
error: err,
requestId,
});
});
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
// TODO simplify and do encoding in frontend service
export const setupRequestHeadersForImages = (jiraCfg: JiraCfg): void => {
const { host, protocol } = parseHostAndPort(jiraCfg);
const getRequestId = (request: unknown): string =>
isRecord(request) && typeof request.requestId === 'string'
? request.requestId.slice(0, MAX_REQUEST_ID_LENGTH)
: '';
// TODO export to util fn
const _b64EncodeUnicode = (str: string): string => {
return Buffer.from(str || '').toString('base64');
};
const encoded = _b64EncodeUnicode(`${jiraCfg.userName}:${jiraCfg.password}`);
const filter = {
urls: [`${protocol}://${host}/*`],
};
const validateHeaders = (headers: unknown): Record<string, string> => {
if (!isRecord(headers)) {
throw new Error('Invalid Jira request headers');
}
// thankfully only the last attached listener will be used
// @see: https://electronjs.org/docs/api/web-request
session.defaultSession.webRequest.onBeforeSendHeaders(filter, (details, callback) => {
if (jiraCfg.usePAT) {
details.requestHeaders.authorization = `Bearer ${jiraCfg.password}`;
} else {
details.requestHeaders.authorization = `Basic ${encoded}`;
const entries = Object.entries(headers);
let byteLength = 0;
for (const [name, value] of entries) {
if (!name || typeof value !== 'string') {
throw new Error('Invalid Jira request headers');
}
callback({ requestHeaders: details.requestHeaders });
});
byteLength += Buffer.byteLength(name) + Buffer.byteLength(value);
}
if (byteLength > MAX_HEADER_BYTES) {
throw new Error('Jira request headers are too large');
}
return Object.fromEntries(entries) as Record<string, string>;
};
const MATCH_PROTOCOL_REG_EX = /(^[^:]+):\/\//;
const MATCH_PORT_REG_EX = /:\d{2,4}/;
const parseHostAndPort = (
config: JiraCfg,
): { host: string; protocol: string; port: number | undefined } => {
let host: string = config.host as string;
let protocol;
let port;
if (!host) {
throw new Error('No host given');
const validateJiraRequest = (request: unknown): JiraElectronRequest => {
if (!isRecord(request)) {
throw new Error('Invalid Jira request');
}
// parse port from host and remove it
if (host.match(MATCH_PORT_REG_EX)) {
const match = MATCH_PORT_REG_EX.exec(host) as RegExpExecArray;
host = host.replace(MATCH_PORT_REG_EX, '');
port = parseInt(match[0].replace(':', ''), 10);
if (
typeof request.requestId !== 'string' ||
request.requestId.length === 0 ||
request.requestId.length > MAX_REQUEST_ID_LENGTH
) {
throw new Error('Invalid Jira request id');
}
// parse protocol from host and remove it
if (host.match(MATCH_PROTOCOL_REG_EX)) {
const match = MATCH_PROTOCOL_REG_EX.exec(host);
host = host
.replace(MATCH_PROTOCOL_REG_EX, '')
// remove trailing slash just in case
.replace(/\/$/, '');
protocol = (match as any)[1];
} else {
protocol = 'https';
if (
typeof request.url !== 'string' ||
request.url.length === 0 ||
request.url.length > MAX_URL_LENGTH
) {
throw new Error('Invalid Jira URL');
}
// log({host, protocol, port});
return { host, protocol, port };
let parsedUrl: URL;
try {
parsedUrl = new URL(request.url);
} catch {
throw new Error('Invalid Jira URL');
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error('Jira URL must use HTTP or HTTPS');
}
if (!isRecord(request.requestInit)) {
throw new Error('Invalid Jira request options');
}
const { method, headers, body } = request.requestInit;
if (method !== 'GET' && method !== 'POST' && method !== 'PUT') {
throw new Error('Invalid Jira request method');
}
if (body !== undefined && typeof body !== 'string') {
throw new Error('Invalid Jira request body');
}
if (typeof body === 'string' && Buffer.byteLength(body) > MAX_BODY_BYTES) {
throw new Error('Jira request body is too large');
}
if (
request.allowSelfSignedCertificate !== undefined &&
typeof request.allowSelfSignedCertificate !== 'boolean'
) {
throw new Error('Invalid Jira certificate setting');
}
return {
requestId: request.requestId,
url: parsedUrl.href,
requestInit: {
method,
headers: validateHeaders(headers),
...(typeof body === 'string' ? { body } : {}),
},
allowSelfSignedCertificate: request.allowSelfSignedCertificate === true,
};
};
const errorMessage = (error: unknown): string => {
const message = error instanceof Error ? error.message : 'Jira request failed';
return message.slice(0, MAX_ERROR_MESSAGE_LENGTH) || 'Jira request failed';
};
const discardResponseBody = (response: Response): void => {
const body = response.body as unknown;
if (isRecord(body) && typeof body.destroy === 'function') {
body.destroy.call(body);
}
};
export const executeJiraRequest = async (
rawRequest: unknown,
fetchImplementation: FetchImplementation = fetch,
agentFactory: AgentFactory = createProxyAwareAgent,
): Promise<JiraElectronResponse> => {
const requestId = getRequestId(rawRequest);
try {
const request = validateJiraRequest(rawRequest);
const response = await fetchWithSafeRedirects(
request,
fetchImplementation,
agentFactory,
);
if (!response.ok) {
discardResponseBody(response);
return {
requestId: request.requestId,
error: {
// Response bodies can contain issue data or internal server details.
// Keep them out of the renderer and its exportable logs.
message: `HTTP ${response.status}`,
status: response.status,
},
};
}
const text = await response.text();
let parsedResponse: unknown = {};
if (text) {
try {
parsedResponse = JSON.parse(text) as unknown;
} catch {
parsedResponse = text;
}
}
return {
requestId: request.requestId,
response: parsedResponse,
};
} catch (error) {
return {
requestId,
error: { message: errorMessage(error) },
};
}
};
const isSafeRedirect = (currentUrl: URL, nextUrl: URL): boolean =>
nextUrl.origin === currentUrl.origin ||
(currentUrl.protocol === 'http:' &&
nextUrl.protocol === 'https:' &&
nextUrl.hostname === currentUrl.hostname);
const fetchWithSafeRedirects = async (
request: JiraElectronRequest,
fetchImplementation: FetchImplementation,
agentFactory: AgentFactory,
): Promise<Response> => {
let currentUrl = request.url;
let method = request.requestInit.method;
let body = request.requestInit.body;
for (let redirectCount = 0; ; redirectCount += 1) {
const agent = agentFactory(currentUrl, request.allowSelfSignedCertificate);
const response = await fetchImplementation(currentUrl, {
method,
headers: request.requestInit.headers,
...(body !== undefined ? { body } : {}),
...(agent ? { agent } : {}),
redirect: 'manual',
timeout: JIRA_MAIN_REQUEST_TIMEOUT_MS,
size: JIRA_MAX_RESPONSE_BYTES,
});
if (!REDIRECT_STATUS_CODES.has(response.status)) {
return response;
}
const location = response.headers?.get('location');
if (!location) {
return response;
}
discardResponseBody(response);
if (redirectCount >= MAX_REDIRECTS) {
throw new Error('Too many Jira redirects');
}
const currentParsedUrl = new URL(currentUrl);
const nextUrl = new URL(location, currentParsedUrl);
if (!isSafeRedirect(currentParsedUrl, nextUrl)) {
throw new Error('Unsafe Jira redirect blocked');
}
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) && method === 'POST')
) {
method = 'GET';
body = undefined;
}
currentUrl = nextUrl.href;
}
};

View file

@ -31,6 +31,7 @@ import { loadSimpleStoreAll } from './simple-store';
import { SimpleStoreKey } from './shared-with-frontend/simple-store.const';
import { markGpuStartupSuccess } from './gpu-startup-guard';
import { isAppOriginUrl } from './navigation-guard';
import { applyJiraImageAuth } from './jira-image-auth';
let mainWin: BrowserWindow;
@ -244,6 +245,7 @@ export const createWindow = async ({
) {
removeKeyInAnyCase(requestHeaders, 'User-Agent');
}
applyJiraImageAuth(details.url, requestHeaders, details.resourceType);
callback({ requestHeaders });
});

View file

@ -0,0 +1,94 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
require('ts-node/register/transpile-only');
const { IPC } = require(
path.resolve(__dirname, 'shared-with-frontend/ipc-events.const.ts'),
);
const {
createJiraPreloadApiConsumer,
toPayloadOnlyIpcListener,
} = require(path.resolve(__dirname, 'shared-with-frontend/preload-api.ts'));
test('renderer listeners receive payloads without the Electron event object', () => {
const received = [];
const listener = toPayloadOnlyIpcListener((...args) => received.push(args));
const rawEvent = {
sender: {
invoke: () => {
throw new Error('must not be exposed');
},
},
};
listener(rawEvent, { safe: true }, 'payload');
assert.deepEqual(received, [[{ safe: true }, 'payload']]);
assert.notEqual(received[0][0], rawEvent);
});
test('Jira preload capability registers eagerly and can only be consumed once', async () => {
const invocations = [];
const invoke = async (channel, ...args) => {
invocations.push([channel, ...args]);
if (channel === IPC.JIRA_REGISTER_CAPABILITY) {
return 'main-issued-token';
}
return { requestId: 'request-1', response: { ok: true } };
};
const consume = createJiraPreloadApiConsumer(invoke);
assert.deepEqual(invocations, [[IPC.JIRA_REGISTER_CAPABILITY]]);
const api = consume();
assert.ok(api);
assert.equal(consume(), null);
assert.equal(consume(), null);
const request = {
requestId: 'request-1',
url: 'https://jira.example.com/rest/api/latest/myself',
requestInit: { method: 'GET', headers: {} },
allowSelfSignedCertificate: false,
};
await api.makeRequest(request);
await api.clearImgHeaders();
assert.deepEqual(invocations[1], [
IPC.JIRA_MAKE_REQUEST_EVENT,
{
capabilityToken: 'main-issued-token',
payload: request,
},
]);
assert.deepEqual(invocations[2], [
IPC.JIRA_CLEAR_IMG_HEADERS,
{
capabilityToken: 'main-issued-token',
payload: null,
},
]);
});
test('Jira preload API refuses calls when main does not issue a capability', async () => {
const invocations = [];
const invoke = async (channel, ...args) => {
invocations.push([channel, ...args]);
return null;
};
const api = createJiraPreloadApiConsumer(invoke)();
await assert.rejects(
() =>
api.makeRequest({
requestId: 'request-1',
url: 'https://jira.example.com',
requestInit: { method: 'GET', headers: {} },
allowSelfSignedCertificate: false,
}),
/unavailable/i,
);
assert.deepEqual(invocations, [[IPC.JIRA_REGISTER_CAPABILITY]]);
});

View file

@ -1,10 +1,4 @@
import {
ipcRenderer,
IpcRendererEvent,
webFrame,
contextBridge,
webUtils,
} from 'electron';
import { ipcRenderer, webFrame, contextBridge, webUtils } from 'electron';
import { ElectronAPI } from './electronAPI.d';
import { IS_GNOME_DESKTOP, IS_GNOME_WAYLAND } from './common.const';
import { IPC, IPCEventValue } from './shared-with-frontend/ipc-events.const';
@ -21,6 +15,10 @@ import {
LocalRestApiRequestPayload,
LocalRestApiResponsePayload,
} from './shared-with-frontend/local-rest-api.model';
import {
createJiraPreloadApiConsumer,
toPayloadOnlyIpcListener,
} from './shared-with-frontend/preload-api';
let pluginNodeExecutionApiConsumed = false;
@ -31,13 +29,12 @@ const _invoke: (channel: IPCEventValue, ...args: unknown[]) => Promise<unknown>
...args
) => ipcRenderer.invoke(channel, ...args);
const consumeJiraApi = createJiraPreloadApiConsumer(_invoke);
const ea: ElectronAPI = {
on: (
channel: string,
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
) => {
on: (channel: string, listener: (...args: unknown[]) => void) => {
// NOTE: there is no proper way to unsubscribe apart from unsubscribing all
ipcRenderer.on(channel, listener);
ipcRenderer.on(channel, toPayloadOnlyIpcListener(listener));
},
// SYNC
// ----
@ -205,9 +202,6 @@ const ea: ElectronAPI = {
_send('REGISTER_GLOBAL_SHORTCUTS', keyboardCfg),
showFullScreenBlocker: (args) => _send('FULL_SCREEN_BLOCKER', args),
makeJiraRequest: (args) => _send('JIRA_MAKE_REQUEST_EVENT', args),
jiraSetupImgHeaders: (args) => _send('JIRA_SETUP_IMG_HEADERS', args),
backupAppData: (appData) => _send('BACKUP', appData),
updateCurrentTask: (
@ -236,6 +230,8 @@ const ea: ElectronAPI = {
ipcRenderer.on('SWITCH_TASK', (_: any, taskId: string) => listener(taskId));
},
consumeJiraApi: () => consumeJiraApi(),
// Plugin API
consumePluginNodeExecutionApi: () => {
if (pluginNodeExecutionApiConsumed) {

View file

@ -4,9 +4,10 @@ export enum IPC {
EXIT = 'EXIT',
RELAUNCH = 'RELAUNCH',
JIRA_CB_EVENT = 'JIRA_RESPONSE',
JIRA_MAKE_REQUEST_EVENT = 'JIRA_MAKE_REQUEST_EVENT',
JIRA_SETUP_IMG_HEADERS = 'JIRA_SETUP_IMG_HEADERS',
JIRA_CLEAR_IMG_HEADERS = 'JIRA_CLEAR_IMG_HEADERS',
JIRA_REGISTER_CAPABILITY = 'JIRA_REGISTER_CAPABILITY',
REGISTER_GLOBAL_SHORTCUTS_EVENT = 'REGISTER_GLOBAL_SHORTCUTS',
IDLE_TIME = 'IDLE_TIME',
RESUME = 'RESUME',

View file

@ -0,0 +1,44 @@
export const JIRA_MAIN_REQUEST_TIMEOUT_MS = 20_000;
export const JIRA_MAX_RESPONSE_BYTES = 25 * 1024 * 1024;
export type JiraRequestMethod = 'GET' | 'POST' | 'PUT';
export interface JiraElectronRequestInit {
method: JiraRequestMethod;
headers: Record<string, string>;
body?: string;
}
export interface JiraElectronRequest {
requestId: string;
url: string;
requestInit: JiraElectronRequestInit;
allowSelfSignedCertificate: boolean;
}
export interface JiraElectronResponse {
requestId: string;
response?: unknown;
error?: {
status?: number;
message: string;
};
}
export interface JiraCapabilityEnvelope<TPayload> {
capabilityToken: string;
payload: TPayload;
}
export interface JiraImageAuthConfig {
host: string | null;
userName: string | null;
password?: string | null;
usePAT: boolean;
}
export interface JiraElectronApi {
makeRequest(request: JiraElectronRequest): Promise<JiraElectronResponse>;
setupImgHeaders(config: JiraImageAuthConfig): Promise<void>;
clearImgHeaders(): Promise<void>;
}

View file

@ -0,0 +1,16 @@
/**
* Creates a preload API consumer whose capability is returned exactly once.
* Trusted startup code claims the capability before untrusted plugin code runs.
*/
export const createOneShotApiConsumer = <T>(factory: () => T): (() => T | null) => {
let isConsumed = false;
return () => {
if (isConsumed) {
return null;
}
isConsumed = true;
return factory();
};
};

View file

@ -0,0 +1,52 @@
import { IPC, IPCEventValue } from './ipc-events.const';
import {
JiraCapabilityEnvelope,
JiraElectronApi,
JiraElectronResponse,
} from './jira-request.model';
import { createOneShotApiConsumer } from './one-shot-api-consumer';
type Invoke = (channel: IPCEventValue, ...args: unknown[]) => Promise<unknown>;
type PayloadListener = (...args: unknown[]) => void;
export const toPayloadOnlyIpcListener =
(listener: PayloadListener) =>
(_event: unknown, ...args: unknown[]): void =>
listener(...args);
export const createJiraPreloadApiConsumer = (
invoke: Invoke,
): (() => JiraElectronApi | null) => {
// Register before renderer code runs. The token remains inside this isolated
// preload closure and is never exposed through the context bridge.
const capabilityTokenPromise = invoke(IPC.JIRA_REGISTER_CAPABILITY).then((token) =>
typeof token === 'string' && token.length > 0 ? token : null,
);
const invokeWithCapability = async <TResponse, TPayload>(
channel: IPCEventValue,
payload: TPayload,
): Promise<TResponse> => {
const capabilityToken = await capabilityTokenPromise;
if (!capabilityToken) {
throw new Error('Jira Electron API is unavailable');
}
const envelope: JiraCapabilityEnvelope<TPayload> = {
capabilityToken,
payload,
};
return invoke(channel, envelope) as Promise<TResponse>;
};
return createOneShotApiConsumer<JiraElectronApi>(() => ({
makeRequest: (request) =>
invokeWithCapability<JiraElectronResponse, typeof request>(
IPC.JIRA_MAKE_REQUEST_EVENT,
request,
),
setupImgHeaders: (config) =>
invokeWithCapability<void, typeof config>(IPC.JIRA_SETUP_IMG_HEADERS, config),
clearImgHeaders: () =>
invokeWithCapability<void, null>(IPC.JIRA_CLEAR_IMG_HEADERS, null),
}));
};