feat: non-blocking bulk-add, CORS hardening, credential preset access control (#31)
* devices: hide discovered neighbors that are already managed Discovered Devices showed every unresolved MikroTik neighbor, even when the neighbor was obviously the same device a managed node is reporting through a different link / MAC / alias. Since we already know every managed device interface MAC, primary IP, and system identity, we can flag those rows. - /api/devices/discovered now annotates each row with duplicate_of_device_id and duplicate_of_device_name when the neighbor matches a managed device by interface MAC, primary IP, or system identity. - Add a Hide duplicates of managed devices toggle (default on) above the table, with a count of how many matches were found. When enabled, the list hides matched rows; when disabled, matches stay visible but are dimmed and tagged Already managed so the origin is clear. - Suppress the Add to Manager action for rows that are already a managed device, since adding them would create a second record for the same hardware. Co-authored-by: Denis <denis-ev@users.noreply.github.com> * devices: safer duplicate handling, try-all safeguards, and timezone/firmware fixes Co-authored-by: Denis <denis-ev@users.noreply.github.com> * Non-blocking follow-ups: parsePort util, tests, bulk-add queue, preset rate limit Co-authored-by: Denis <denis-ev@users.noreply.github.com> * fix(security): tighten CORS, Redis rate limits, and preset access controls Require explicit CORS origins in production, align Socket.IO CORS with HTTP, default Docker Compose to local HTTPS origins, and remove the in-memory mutation limiter in favor of Redis. Also add operator-use flags for credential presets in the UI and harden bulk-add job access and client error messages. Co-authored-by: Denis <denis-ev@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
corsMiddlewareOptions,
|
||||
resetCorsResolvedCacheForTests,
|
||||
socketIoCorsOptions,
|
||||
} from '../corsOrigins';
|
||||
|
||||
describe('corsOrigins', () => {
|
||||
const prev = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...prev };
|
||||
resetCorsResolvedCacheForTests();
|
||||
});
|
||||
|
||||
it('uses wildcard in test when CORS_ORIGIN is unset', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.CORS_ORIGIN;
|
||||
expect(corsMiddlewareOptions()).toMatchObject({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
});
|
||||
expect(socketIoCorsOptions()).toMatchObject({
|
||||
origin: '*',
|
||||
credentials: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses comma-separated CORS_ORIGIN in production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.CORS_ORIGIN = 'https://a.example.com, https://b.example.com ';
|
||||
const cors = corsMiddlewareOptions();
|
||||
expect(cors.credentials).toBe(true);
|
||||
expect(typeof cors.origin).toBe('function');
|
||||
const originFn = cors.origin as (
|
||||
o: string | undefined,
|
||||
fn: (e: Error | null, ok?: boolean) => void,
|
||||
) => void;
|
||||
originFn(undefined, (err, ok) => {
|
||||
expect(err).toBeNull();
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
originFn('https://a.example.com', (err, ok) => {
|
||||
expect(err).toBeNull();
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
originFn('https://evil.com', (err, ok) => {
|
||||
expect(err).toBeNull();
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
expect(socketIoCorsOptions()).toEqual({
|
||||
origin: ['https://a.example.com', 'https://b.example.com'],
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parsePort } from '../parsePort';
|
||||
|
||||
describe('parsePort', () => {
|
||||
it('returns fallback for null, undefined, empty string', () => {
|
||||
expect(parsePort(null, 8728)).toBe(8728);
|
||||
expect(parsePort(undefined, 22)).toBe(22);
|
||||
expect(parsePort('', 8728)).toBe(8728);
|
||||
});
|
||||
|
||||
it('parses numbers and numeric strings', () => {
|
||||
expect(parsePort(8729, 8728)).toBe(8729);
|
||||
expect(parsePort('8729', 8728)).toBe(8729);
|
||||
expect(parsePort('22', 22)).toBe(22);
|
||||
});
|
||||
|
||||
it('returns fallback for NaN and non-numeric strings', () => {
|
||||
expect(parsePort('abc', 8728)).toBe(8728);
|
||||
expect(parsePort(NaN, 22)).toBe(22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CorsOptions } from 'cors';
|
||||
|
||||
/** Browser dev servers and typical local access through nginx. */
|
||||
const DEFAULT_DEV_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://localhost',
|
||||
'http://127.0.0.1',
|
||||
'https://localhost',
|
||||
'https://127.0.0.1',
|
||||
];
|
||||
|
||||
type Resolved =
|
||||
| { kind: 'wildcard' }
|
||||
| { kind: 'list'; allowed: string[] };
|
||||
|
||||
function parseList(raw: string): string[] {
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveCors(): Resolved {
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const raw = process.env.CORS_ORIGIN?.trim();
|
||||
|
||||
if (raw === '*') {
|
||||
if (nodeEnv === 'production') {
|
||||
console.error(
|
||||
'CORS_ORIGIN=* is not allowed in production. Set a comma-separated list of browser origins (e.g. https://manager.example.com).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return { kind: 'wildcard' };
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
if (nodeEnv === 'production') {
|
||||
console.error(
|
||||
'CORS_ORIGIN is required in production (comma-separated origins, e.g. https://manager.example.com).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (nodeEnv === 'test') {
|
||||
return { kind: 'wildcard' };
|
||||
}
|
||||
return { kind: 'list', allowed: DEFAULT_DEV_ORIGINS };
|
||||
}
|
||||
|
||||
return { kind: 'list', allowed: parseList(raw) };
|
||||
}
|
||||
|
||||
let resolvedCache: Resolved | null = null;
|
||||
|
||||
function getResolved(): Resolved {
|
||||
if (!resolvedCache) resolvedCache = resolveCors();
|
||||
return resolvedCache;
|
||||
}
|
||||
|
||||
/** Clears memoized CORS resolution (for Jest when env changes between cases). */
|
||||
export function resetCorsResolvedCacheForTests(): void {
|
||||
resolvedCache = null;
|
||||
}
|
||||
|
||||
/** Express `cors` middleware options (no `origin: *` with `credentials: true`). */
|
||||
export function corsMiddlewareOptions(): CorsOptions {
|
||||
const resolved = getResolved();
|
||||
if (resolved.kind === 'wildcard') {
|
||||
return { origin: '*', credentials: false };
|
||||
}
|
||||
const allowed = resolved.allowed;
|
||||
return {
|
||||
origin(origin, callback) {
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
if (allowed.includes(origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(null, false);
|
||||
},
|
||||
credentials: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Socket.IO server `cors` field — must align with HTTP CORS. */
|
||||
export function socketIoCorsOptions(): {
|
||||
origin: string | string[];
|
||||
methods: string[];
|
||||
credentials: boolean;
|
||||
} {
|
||||
const resolved = getResolved();
|
||||
if (resolved.kind === 'wildcard') {
|
||||
return { origin: '*', methods: ['GET', 'POST'], credentials: false };
|
||||
}
|
||||
return {
|
||||
origin: resolved.allowed,
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Parse API/SSH port from request bodies (string or number). Invalid values
|
||||
* fall back to the provided default (matches existing device routes behavior).
|
||||
*/
|
||||
export function parsePort(value: unknown, fallback: number): number {
|
||||
if (value == null || value === '') return fallback;
|
||||
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Return a short, stable message for API clients; log full detail server-side only.
|
||||
*/
|
||||
export function safeConnectionError(context: string, err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[${context}]`, raw);
|
||||
return 'Cannot connect to device. Check IP, port, credentials, and that the API service is reachable.';
|
||||
}
|
||||
Reference in New Issue
Block a user