fix: harden auth site access and SSH validation
This commit is contained in:
@@ -2,11 +2,13 @@ import { Router, Request, Response } from 'express';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag } from '../utils/siteScope';
|
||||
import { activeSite } from '../middleware/site';
|
||||
import { siteScopeDevices, siteScopeByDevice, siteScopeByNullableDevice, clientSeriesTag, singleSiteOrNull } from '../utils/siteScope';
|
||||
import { activeSite, enforceSiteAccess, deviceIdSiteGuard } from '../middleware/site';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
router.use(enforceSiteAccess);
|
||||
router.param('deviceId', deviceIdSiteGuard);
|
||||
|
||||
function rangeToFlux(range: string): string {
|
||||
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
|
||||
@@ -25,7 +27,15 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
// the global series there is not a shortcut -- it keeps the chart's full
|
||||
// history, which the per-site series only accumulates from the upgrade
|
||||
// onward. That covers every single-site install.
|
||||
const siteId = activeSite(req);
|
||||
// clientSeriesTag needs one concrete site id or the true fleet-wide case.
|
||||
// SEC-002: a restricted user viewing several sites at once has neither --
|
||||
// there is no single dedup series for "these particular sites", and
|
||||
// falling back to `_global` would hand them the whole fleet's count. Such a
|
||||
// scope skips the dedup series entirely and goes straight to the per-device
|
||||
// fallback below, filtered to exactly the devices they may see.
|
||||
const scope = activeSite(req);
|
||||
const siteId = singleSiteOrNull(scope);
|
||||
const restrictedToMultipleSites = Array.isArray(scope);
|
||||
let seriesTag = '_global';
|
||||
if (siteId !== null) {
|
||||
const counts = await query<{ in_site: number; total: number }>(
|
||||
@@ -40,29 +50,31 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
|
||||
// Use the global deduplicated metric (_global tag) written by DeviceCollector.
|
||||
// This avoids double-counting clients seen by multiple devices simultaneously.
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|
||||
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|
||||
|> yield(name: "clients_over_time")
|
||||
`;
|
||||
|
||||
const rawPoints: { time: string; value: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB might not have data yet
|
||||
if (!restrictedToMultipleSites) {
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_counts")
|
||||
|> filter(fn: (r) => r._field == "total_clients")
|
||||
|> filter(fn: (r) => r.device_id == "${seriesTag}")
|
||||
|> aggregateWindow(every: 5m, fn: last, createEmpty: false)
|
||||
|> yield(name: "clients_over_time")
|
||||
`;
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const value = tableMeta.get(row, '_value') as number;
|
||||
rawPoints.push({ time, value: Math.round(value) });
|
||||
});
|
||||
} catch {
|
||||
// InfluxDB might not have data yet
|
||||
}
|
||||
}
|
||||
|
||||
// If no deduplicated series exists yet (first run, or a site whose history
|
||||
// predates the per-site series), fall back to max across real devices. max()
|
||||
// predates the per-site series), or the caller is scoped to several sites
|
||||
// at once, fall back to max across the exact devices they may see. max()
|
||||
// is better than sum() since the device with the most clients is the gateway
|
||||
// that sees everyone — summing would double-count clients visible from
|
||||
// multiple devices.
|
||||
@@ -72,12 +84,16 @@ router.get('/clients-over-time', async (req: Request, res: Response) => {
|
||||
// it matters: without it the fleet-wide fallback would fold the per-site
|
||||
// totals back in and count everyone twice.
|
||||
let deviceFilter: string | null = `|> filter(fn: (r) => not (r.device_id =~ /^_/))`;
|
||||
if (siteId !== null) {
|
||||
const ids = await query<{ id: number }>(
|
||||
`SELECT id FROM devices WHERE site_id = $1`, [siteId]
|
||||
);
|
||||
// An empty site has no series to fall back to, and must show an empty
|
||||
// chart rather than the fleet's (issue #130).
|
||||
if (scope !== null) {
|
||||
const scopePredicate = Array.isArray(scope) ? scope : [scope];
|
||||
const ids = scopePredicate.length
|
||||
? await query<{ id: number }>(
|
||||
`SELECT id FROM devices WHERE site_id = ANY($1::int[])`, [scopePredicate]
|
||||
)
|
||||
: [];
|
||||
// An empty site (or a viewer authorized for zero sites) has no series
|
||||
// to fall back to, and must show an empty chart rather than the fleet's
|
||||
// (issue #130 / SEC-002).
|
||||
deviceFilter = ids.length
|
||||
? `|> filter(fn: (r) => ${ids.map((d) => `r.device_id == "${d.id}"`).join(' or ')})`
|
||||
: null;
|
||||
|
||||
Reference in New Issue
Block a user