super-productivity/electron/local-rest-api.ts
Johannes Millan 4d8605baa3 fix(security): harden XSS sinks and add Origin check (#7413)
Address Snyk findings raised in discussion #7413:

- error-handler: inject error title, additionalLog, stackTrace, and
  meta into the global error alert via textContent/setAttribute instead
  of innerHTML interpolation. Also fixes a latent <h2> close-tag typo.
- break-reminder-overlay: use textContent (not innerHTML) for the
  reminder message; switch to decodeURIComponent to match a producer-
  side encodeURIComponent change (also fixes a latent parse bug for
  messages containing & or =).
- local-rest-api: reject any request that arrives with a web Origin
  header. Closes the simple-POST CSRF gap (text/plain bodies are not
  preflighted by CORS) on top of the existing Host allowlist.

The Snyk findings on Google OAuth CLIENT_SECRET (Desktop client per
RFC 8252) and proxy-agent rejectUnauthorized (opt-in for self-hosted
WebDAV with self-signed certs) are documented false positives and
intentional, respectively; both already carry explanatory comments.
2026-05-06 21:37:06 +02:00

299 lines
7.8 KiB
TypeScript

import { ipcMain } from 'electron';
import { log, warn } from 'electron-log/main';
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { randomUUID } from 'crypto';
import { IPC } from './shared-with-frontend/ipc-events.const';
import { getIsAppReady, getWin } from './main-window';
import { GlobalConfigState } from '../src/app/features/config/global-config.model';
import {
LOCAL_REST_API_HOST,
LOCAL_REST_API_MAX_BODY_BYTES,
LOCAL_REST_API_MAX_CONCURRENT_REQUESTS,
LOCAL_REST_API_PORT,
LOCAL_REST_API_TIMEOUT_MS,
LocalRestApiRequestPayload,
LocalRestApiResponsePayload,
} from './shared-with-frontend/local-rest-api.model';
const JSON_HEADERS = {
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'Content-Type': 'application/json; charset=utf-8',
};
let server: Server | null = null;
let isInitialized = false;
let isEnabled = false;
let isListening = false;
const pendingRequests = new Map<
string,
{
resolve: (response: LocalRestApiResponsePayload) => void;
timeout: NodeJS.Timeout;
}
>();
const writeJson = (
res: ServerResponse,
status: number,
body: LocalRestApiResponsePayload['body'],
): void => {
const responseJson = JSON.stringify(body);
res.writeHead(status, {
...JSON_HEADERS,
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-Length': Buffer.byteLength(responseJson),
});
res.end(responseJson);
};
const readJsonBody = async (req: IncomingMessage): Promise<unknown> => {
const chunks: Buffer[] = [];
let totalBytes = 0;
for await (const chunk of req) {
const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += bufferChunk.length;
if (totalBytes > LOCAL_REST_API_MAX_BODY_BYTES) {
throw new Error('Request body too large');
}
chunks.push(bufferChunk);
}
if (!chunks.length) {
return undefined;
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
};
const getQueryObject = (url: URL): Record<string, string | string[]> => {
const query: Record<string, string | string[]> = {};
for (const key of new Set(url.searchParams.keys())) {
const values = url.searchParams.getAll(key);
query[key] = values.length <= 1 ? (values[0] ?? '') : values;
}
return query;
};
const forwardRequestToRenderer = async (
payload: LocalRestApiRequestPayload,
): Promise<LocalRestApiResponsePayload> => {
const mainWindow = getWin();
return new Promise<LocalRestApiResponsePayload>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingRequests.delete(payload.requestId);
reject(new Error('Renderer request timed out'));
}, LOCAL_REST_API_TIMEOUT_MS);
pendingRequests.set(payload.requestId, {
resolve,
timeout,
});
mainWindow.webContents.send(IPC.LOCAL_REST_API_REQUEST, payload);
});
};
const handleResponse = (_event: unknown, payload: LocalRestApiResponsePayload): void => {
const pending = pendingRequests.get(payload.requestId);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
pendingRequests.delete(payload.requestId);
pending.resolve(payload);
};
const ALLOWED_HOSTS = new Set([
`${LOCAL_REST_API_HOST}:${LOCAL_REST_API_PORT}`,
`localhost:${LOCAL_REST_API_PORT}`,
LOCAL_REST_API_HOST,
'localhost',
]);
const isForceEnabledForDev = (): boolean =>
process.env.NODE_ENV === 'DEV' && process.env.SP_FORCE_LOCAL_REST_API === '1';
const handleHttpRequest = async (
req: IncomingMessage,
res: ServerResponse,
): Promise<void> => {
// Block DNS rebinding: reject requests with unexpected Host headers
const host = req.headers.host;
if (!host || !ALLOWED_HOSTS.has(host)) {
writeJson(res, 403, {
ok: false,
error: {
code: 'FORBIDDEN',
message: 'Invalid Host header',
},
});
return;
}
// Block browser-CSRF: reject any request that arrives with a web Origin.
// The intended consumers are CLI tools and scripts (no Origin header).
// Browsers always set Origin on cross-origin POSTs (and on simple POSTs
// with text/plain bodies, which CORS does not preflight); rejecting here
// closes that gap on top of the Host-header check above.
const origin = req.headers.origin;
if (origin && origin !== 'null') {
writeJson(res, 403, {
ok: false,
error: {
code: 'FORBIDDEN',
message: 'Requests from web origins are not allowed',
},
});
return;
}
if (pendingRequests.size >= LOCAL_REST_API_MAX_CONCURRENT_REQUESTS) {
writeJson(res, 429, {
ok: false,
error: {
code: 'TOO_MANY_REQUESTS',
message: `Too many concurrent requests (limit: ${LOCAL_REST_API_MAX_CONCURRENT_REQUESTS})`,
},
});
return;
}
const requestUrl = new URL(req.url ?? '/', `http://${LOCAL_REST_API_HOST}`);
const method = req.method ?? 'GET';
if (method === 'GET' && requestUrl.pathname === '/health') {
writeJson(res, 200, {
ok: true,
data: {
server: 'up',
rendererReady: getIsAppReady(),
},
});
return;
}
if (!getIsAppReady()) {
writeJson(res, 503, {
ok: false,
error: {
code: 'APP_NOT_READY',
message: 'Renderer is not ready yet',
},
});
return;
}
let body: unknown;
try {
body = await readJsonBody(req);
} catch (error) {
writeJson(res, 400, {
ok: false,
error: {
code: 'INVALID_REQUEST_BODY',
message: error instanceof Error ? error.message : 'Invalid request body',
},
});
return;
}
try {
const rendererResponse = await forwardRequestToRenderer({
requestId: randomUUID(),
method,
path: requestUrl.pathname,
query: getQueryObject(requestUrl),
body,
});
writeJson(res, rendererResponse.status, rendererResponse.body);
} catch (error) {
warn('[local-rest-api] Request failed', requestUrl.pathname, error);
const isTimeout =
error instanceof Error && error.message === 'Renderer request timed out';
writeJson(res, isTimeout ? 504 : 500, {
ok: false,
error: {
code: isTimeout ? 'RENDERER_TIMEOUT' : 'INTERNAL_ERROR',
message: error instanceof Error ? error.message : 'Unknown internal error',
},
});
}
};
export const initLocalRestApi = (): void => {
if (isInitialized) {
return;
}
isInitialized = true;
ipcMain.on(IPC.LOCAL_REST_API_RESPONSE, handleResponse);
server = createServer((req, res) => {
void handleHttpRequest(req, res);
});
server.on('error', (error) => {
isListening = false;
warn('[local-rest-api] Server error', error);
});
if (isForceEnabledForDev()) {
warn('[local-rest-api] Enabled by SP_FORCE_LOCAL_REST_API=1 for DEV runtime');
isEnabled = true;
startServer();
}
};
const startServer = (): void => {
if (!server || isListening) {
return;
}
server.listen(LOCAL_REST_API_PORT, LOCAL_REST_API_HOST, () => {
isListening = true;
log(
`[local-rest-api] Listening on http://${LOCAL_REST_API_HOST}:${LOCAL_REST_API_PORT}`,
);
});
};
const stopServer = (): void => {
if (!server || !isListening) {
return;
}
server.close((error) => {
if (error) {
warn('[local-rest-api] Failed to stop server', error);
return;
}
isListening = false;
log('[local-rest-api] Server stopped');
});
};
export const updateLocalRestApiConfig = (cfg: GlobalConfigState): void => {
const isForcedForDev = isForceEnabledForDev();
const nextEnabled = isForcedForDev || !!cfg.misc.isLocalRestApiEnabled;
if (nextEnabled === isEnabled) {
if (nextEnabled && !isListening) {
startServer();
} else if (!nextEnabled && isListening) {
stopServer();
}
return;
}
isEnabled = nextEnabled;
if (isEnabled) {
startServer();
} else {
stopServer();
}
};