super-productivity/electron/jira-image-auth.ts
Johannes Millan 95d3b212bc
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
2026-07-14 21:02:27 +02:00

100 lines
2.9 KiB
TypeScript

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;
};