mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* 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
230 lines
6.7 KiB
TypeScript
230 lines
6.7 KiB
TypeScript
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';
|
|
|
|
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]);
|
|
|
|
type FetchImplementation = (url: string, init: RequestInit) => Promise<Response>;
|
|
type AgentFactory = typeof createProxyAwareAgent;
|
|
|
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
|
|
const getRequestId = (request: unknown): string =>
|
|
isRecord(request) && typeof request.requestId === 'string'
|
|
? request.requestId.slice(0, MAX_REQUEST_ID_LENGTH)
|
|
: '';
|
|
|
|
const validateHeaders = (headers: unknown): Record<string, string> => {
|
|
if (!isRecord(headers)) {
|
|
throw new Error('Invalid Jira request headers');
|
|
}
|
|
|
|
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');
|
|
}
|
|
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 validateJiraRequest = (request: unknown): JiraElectronRequest => {
|
|
if (!isRecord(request)) {
|
|
throw new Error('Invalid Jira request');
|
|
}
|
|
|
|
if (
|
|
typeof request.requestId !== 'string' ||
|
|
request.requestId.length === 0 ||
|
|
request.requestId.length > MAX_REQUEST_ID_LENGTH
|
|
) {
|
|
throw new Error('Invalid Jira request id');
|
|
}
|
|
|
|
if (
|
|
typeof request.url !== 'string' ||
|
|
request.url.length === 0 ||
|
|
request.url.length > MAX_URL_LENGTH
|
|
) {
|
|
throw new Error('Invalid Jira URL');
|
|
}
|
|
|
|
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;
|
|
}
|
|
};
|