mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 17:05:48 +00:00
* 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>
272 lines
6.8 KiB
TypeScript
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();
|
|
}
|
|
};
|