super-productivity/electron/local-rest-api.ts
Johannes Millan 4b0550be35
fix(security): harden Local REST API against DNS rebinding and field injection (#6996)
* fix(security): harden Local REST API against DNS rebinding and field injection

- Add Host header validation to block DNS rebinding attacks from
  malicious websites targeting the localhost API
- Add IS_ELECTRON guard in handler init() for defense-in-depth
- Add allowlist of task fields settable via API (title, notes, isDone,
  timeEstimate, timeSpent, projectId, tagIds, dueDay, dueWithTime,
  plannedAt) to prevent state corruption via injected internal fields
- Validate source query parameter instead of blind cast
- Add tests verifying disallowed fields are stripped from POST and PATCH

https://claude.ai/code/session_01YJNc4gUCkHHrAAhXJBEaop

* fix: address review findings from daily code review

Security hardening:
- Block cloud metadata endpoints (169.254.169.254, metadata.google.internal)
  even when allowPrivateNetwork is true (SSRF protection)
- Add HTTP method allowlist to PluginHttp request() to prevent abuse
- Add rate limiting (max 50 concurrent) to Local REST API

Bug fixes:
- Fix hardcoded 'ICAL' issueProviderKey in calendar effects; use event's
  actual provider key with ICAL fallback
- Fix race condition in TimeBlockDeleteSidecarService: push IDs instead
  of replacing to prevent data loss on rapid bulk deletes
- Backfill missing issueProviderKey on cached calendar events from
  localStorage (backward compatibility)
- Fix btoa(String.fromCharCode(...spread)) overflow for long strings
  by using loop-based approach

Minor:
- Cap HiddenCalendarEventsService to 500 entries max (prevent
  unbounded localStorage growth)
- Fix misleading "deep clone" comment to "shallow clone"

https://claude.ai/code/session_01YJNc4gUCkHHrAAhXJBEaop

* test: fix unit test failures from review hardening changes

- Replace IS_ELECTRON guard with window.ea?.onLocalRestApiRequest check
  in handler init() so tests can mock the API without navigator.userAgent
- Update plugin-http spec to expect "cloud metadata endpoints" error
  message (was "private/local") for 169.254.169.254 and metadata.google
- Fix allowlist test expectation: title is an allowed field and passes
  through pickAllowedFields as expected

https://claude.ai/code/session_01YJNc4gUCkHHrAAhXJBEaop

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-29 11:59:27 +02:00

272 lines
6.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 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;
}
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);
});
};
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 nextEnabled = !!cfg.misc.isLocalRestApiEnabled;
if (nextEnabled === isEnabled) {
if (nextEnabled && !isListening) {
startServer();
} else if (!nextEnabled && isListening) {
stopServer();
}
return;
}
isEnabled = nextEnabled;
if (isEnabled) {
startServer();
} else {
stopServer();
}
};