security: a key for the network test probe (08 §7 #9)
Settings → Network makes a key, shown once and stored encrypted; the api sends it to the probe as a Bearer token, and a new probe address drops it. A probe with PROBE_KEY only answers that key. Without one it stays open, and Diagnostics strongly recommends setting it.
This commit is contained in:
1 parent
958405651b
commit
60348cf0c5
14 files changed
+233
-14
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/settings (04 §7.3) and the dashboard warnings. Admin only.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { eq, inArray, isNotNull } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
@@ -63,6 +64,7 @@ const READ_ONLY = new Set<string>([
|
||||
'spam.state',
|
||||
'sms.webhookToken',
|
||||
'setup.completed',
|
||||
'network.probeKeyEnc',
|
||||
]);
|
||||
|
||||
async function toDto(db: Db): Promise<SettingsDto> {
|
||||
@@ -72,10 +74,12 @@ async function toDto(db: Db): Promise<SettingsDto> {
|
||||
'auth.oidc': oidc,
|
||||
'auth.ldap': ldap,
|
||||
spam,
|
||||
'network.probeKeyEnc': probeKey,
|
||||
...rest
|
||||
} = await getAllSettings(db);
|
||||
return {
|
||||
...rest,
|
||||
'network.hasProbeKey': probeKey !== null,
|
||||
spam: {
|
||||
...spam,
|
||||
ipqs: spam.ipqs && { hasKey: true },
|
||||
@@ -356,6 +360,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const cert = await readTlsCert(app.config.TLS_DIR);
|
||||
if (!cert.ok) throw badRequest(`Can't turn on TLS. ${cert.error}`);
|
||||
}
|
||||
// The probe's key was made for its address: another probe doesn't get it (08 §7 #9).
|
||||
if ('network.probeUrl' in values && values['network.probeUrl'] !== before['network.probeUrl'])
|
||||
values['network.probeKeyEnc'] = null;
|
||||
for (const [key, value] of Object.entries(values))
|
||||
await setSetting(db, key as SettingKey, value as never);
|
||||
// People's own Contrack tokens were for the old address: they connect again (08 §7 #7).
|
||||
@@ -487,6 +494,25 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* A new key for the network test probe (08 §7 #9): shown this once, for its PROBE_KEY. Made for
|
||||
* the probe address set now; the old key stops working on the next test.
|
||||
*/
|
||||
app.post('/api/settings/network/probe-key', async (req) => {
|
||||
if (!(await getSetting(db, 'network.probeUrl')))
|
||||
throw badRequest('Set the probe address first');
|
||||
const key = randomBytes(32).toString('base64url');
|
||||
await setSetting(db, 'network.probeKeyEnc', encrypt(key));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'settings',
|
||||
summary: 'Made a new network test probe key',
|
||||
});
|
||||
return { key };
|
||||
});
|
||||
|
||||
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
|
||||
app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
|
||||
const ip = await detectPublicIp();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BlockList, isIPv4 } from 'node:net';
|
||||
import type { DiagnosticCheck, NetworkTestDto } from '@selfphoneng/shared';
|
||||
import type { AmiClient } from '../ami/client.ts';
|
||||
import type { Config } from '../config.ts';
|
||||
import { decrypt } from '../crypto/secrets.ts';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { detectPublicIp } from '../jobs.ts';
|
||||
import { getAllSettings } from './settings.ts';
|
||||
@@ -221,15 +222,23 @@ export async function insideChecks(d: DiagDeps): Promise<DiagnosticCheck[]> {
|
||||
);
|
||||
|
||||
out.push(
|
||||
s['network.probeUrl']
|
||||
? check('probe', 'Network test probe', 'ok', s['network.probeUrl'])
|
||||
: check(
|
||||
!s['network.probeUrl']
|
||||
? check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'skip',
|
||||
'None set, so nothing is tested from outside your network.',
|
||||
'Run apps/probe on a machine outside this network (deploy/probe), then put its URL in Settings → Network.',
|
||||
),
|
||||
)
|
||||
: s['network.probeKeyEnc']
|
||||
? check('probe', 'Network test probe', 'ok', `${s['network.probeUrl']}, with its key`)
|
||||
: check(
|
||||
'probe',
|
||||
'Network test probe',
|
||||
'warn',
|
||||
`${s['network.probeUrl']} has no key: anyone who finds it can use it.`,
|
||||
'Settings → Network → Generate key, then set it as PROBE_KEY on the probe and restart it (strongly recommended).',
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
@@ -289,14 +298,20 @@ export async function networkTest(
|
||||
let ip: string | null = null;
|
||||
let results: ProbeResult[] = [];
|
||||
let probeError: string | null = null;
|
||||
let keyRefused = false;
|
||||
const key = s['network.probeKeyEnc'];
|
||||
try {
|
||||
const r = await (d.fetchProbe ?? fetch)(new URL('/v1/check', probe), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(key && { authorization: `Bearer ${decrypt(key)}` }),
|
||||
},
|
||||
body: JSON.stringify({ host, tests }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = (await r.json()) as { ip?: string; results?: ProbeResult[]; error?: string };
|
||||
keyRefused = r.status === 401;
|
||||
if (!r.ok) probeError = body.error ?? `HTTP ${r.status}`;
|
||||
else ({ ip = null, results = [] } = body as { ip: string; results: ProbeResult[] });
|
||||
// Datagrams sent last arrive just after the answer.
|
||||
@@ -316,7 +331,9 @@ export async function networkTest(
|
||||
'Network test probe',
|
||||
'fail',
|
||||
`${probe} didn't answer: ${probeError}`,
|
||||
'Check the probe runs and is reachable from this server, then try again.',
|
||||
keyRefused
|
||||
? "The probe's PROBE_KEY isn't this server's key. Settings → Network → Generate key, set it as PROBE_KEY on the probe and restart it."
|
||||
: 'Check the probe runs and is reachable from this server, then try again.',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -154,4 +154,55 @@ describe.skipIf(!hasTestDb)('diagnostics', () => {
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it('the probe key: made here, shown once, sent to the probe, never in settings (08 §7 #9)', async () => {
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
const as = (method: string, url: string, payload?: object) =>
|
||||
t.app.inject({ method: method as 'GET', url, payload, headers: { ...H, cookie } });
|
||||
await setSetting(t.db, 'network.probeUrl', 'https://probe.test');
|
||||
// No key yet: the inside checks say to make one.
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'warn' });
|
||||
|
||||
const g = await as('POST', '/api/settings/network/probe-key');
|
||||
expect(g.statusCode, g.body).toBe(200);
|
||||
const { key } = g.json() as { key: string };
|
||||
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
const s = await as('GET', '/api/settings');
|
||||
expect(s.json()['network.hasProbeKey']).toBe(true);
|
||||
expect(s.body).not.toContain(key);
|
||||
expect(s.json()).not.toHaveProperty('network.probeKeyEnc');
|
||||
expect((await as('PATCH', '/api/settings', { 'network.probeKeyEnc': 'x' })).statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
expect(byId(await insideChecks(deps)).probe).toMatchObject({ status: 'ok' });
|
||||
|
||||
let auth: string | null = null;
|
||||
await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async (_u: URL, init: RequestInit) => {
|
||||
auth = new Headers(init.headers).get('authorization');
|
||||
return new Response(JSON.stringify({ ip: '203.0.113.10', results: [] }));
|
||||
}) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(auth).toBe(`Bearer ${key}`);
|
||||
// The probe refusing it says what to do.
|
||||
const refused = await networkTest(
|
||||
{
|
||||
...deps,
|
||||
fetchProbe: (async () =>
|
||||
new Response(JSON.stringify({ error: 'a key is needed' }), {
|
||||
status: 401,
|
||||
})) as unknown as typeof fetch,
|
||||
},
|
||||
'quick',
|
||||
);
|
||||
expect(byId(refused.checks).probe!.fix).toContain('PROBE_KEY');
|
||||
|
||||
// Another address: the key was for the old probe, so it's gone.
|
||||
await as('PATCH', '/api/settings', { 'network.probeUrl': 'https://other-probe.test' });
|
||||
expect((await as('GET', '/api/settings')).json()['network.hasProbeKey']).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,19 @@
|
||||
// in Settings → Network. `node src/index.ts` (Node 24 runs TypeScript as is).
|
||||
// PORT (default 8080), TRUST_PROXY=true behind a reverse proxy on this machine that sets
|
||||
// X-Forwarded-For. HOST: where to listen; with TRUST_PROXY=true 127.0.0.1 (only the proxy may
|
||||
// reach it), else every address.
|
||||
// reach it), else every address. PROBE_KEY: the key from SelfPhoneNG's Settings → Network;
|
||||
// without one anyone who finds the probe can use it.
|
||||
import { createProbe } from './server.ts';
|
||||
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
const trustProxy = process.env.TRUST_PROXY === 'true';
|
||||
const host = process.env.HOST ?? (trustProxy ? '127.0.0.1' : '::');
|
||||
const probe = createProbe({ trustProxy });
|
||||
const key = process.env.PROBE_KEY || undefined;
|
||||
const probe = createProbe({ trustProxy, key });
|
||||
probe.listen(port, host, () => console.log(`selfphoneng-probe listening on ${host} ${port}`));
|
||||
if (!key)
|
||||
console.warn(
|
||||
'No PROBE_KEY: anyone who finds this probe can use it. Make one in SelfPhoneNG ' +
|
||||
'(Settings → Network → Generate key) and set PROBE_KEY.',
|
||||
);
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => probe.close(() => process.exit(0)));
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// The probe against stand-ins on 127.0.0.1: TCP, HTTP and a WebSocket upgrade, SIP over UDP and
|
||||
// TCP, raw UDP; and the server: it only tests the address that asked, validates, rate-limits,
|
||||
// and believes X-Forwarded-For only from a proxy on this machine (08 §7 #4).
|
||||
// believes X-Forwarded-For only from a proxy on this machine (08 §7 #4), and with PROBE_KEY only
|
||||
// answers who sends it (#9).
|
||||
import { createSocket, type Socket as UdpSocket } from 'node:dgram';
|
||||
import {
|
||||
createServer as httpServer,
|
||||
@@ -171,6 +172,25 @@ describe('probe server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the probe key', () => {
|
||||
it('with a key, only a request that sends it gets tested (08 §7 #9)', async () => {
|
||||
const key = 'k3y'.repeat(15);
|
||||
const probe = await listen(createProbe({ key }));
|
||||
const post = (authorization?: string) =>
|
||||
fetch(`http://127.0.0.1:${port(probe)}/v1/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(authorization && { authorization }) },
|
||||
body: JSON.stringify({ host: 'pbx.test', tests: [{ id: 'a', kind: 'tcp', port: 1 }] }),
|
||||
});
|
||||
expect((await post()).status).toBe(401);
|
||||
expect((await post('Bearer wrong')).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}x`)).status).toBe(401);
|
||||
expect((await post(`Bearer ${key}`)).status).toBe(200);
|
||||
expect((await fetch(`http://127.0.0.1:${port(probe)}/healthz`)).status).toBe(200);
|
||||
probe.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address that asked', () => {
|
||||
const req = (peer: string, xff?: string) =>
|
||||
({
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// POST /v1/check {host, tests}: runs the tests against the address the request came from and
|
||||
// answers {ip, results}. GET /healthz. No other target can be given, which is what keeps the probe
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes.
|
||||
// from being aimed at someone else; each address gets a few checks per 10 minutes. With a key
|
||||
// (PROBE_KEY, made in SelfPhoneNG's Settings → Network), only who sends it is tested (08 §7 #9).
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import { isIP } from 'node:net';
|
||||
import { runTest, type Result, type Test } from './checks.ts';
|
||||
@@ -9,6 +11,8 @@ import { runTest, type Result, type Test } from './checks.ts';
|
||||
export type ProbeOptions = {
|
||||
/** Behind a reverse proxy: the address is the last X-Forwarded-For entry (the proxy adds it). */
|
||||
trustProxy?: boolean;
|
||||
/** Only requests with `Authorization: Bearer <key>` are tested. None: anyone may ask. */
|
||||
key?: string;
|
||||
/** Checks per address per window. */
|
||||
maxChecks?: number;
|
||||
windowMs?: number;
|
||||
@@ -65,6 +69,10 @@ export const clientIp = (req: IncomingMessage, trustProxy: boolean) => {
|
||||
: peer;
|
||||
};
|
||||
|
||||
/** Equal, in the same time whatever was sent (hashes, so lengths don't show either). */
|
||||
const sameKey = (a: string, b: string) =>
|
||||
timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest());
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
@@ -86,7 +94,7 @@ function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
}
|
||||
|
||||
export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
const { trustProxy = false, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
const { trustProxy = false, key, maxChecks = 10, windowMs = 10 * 60_000 } = opts;
|
||||
// ponytail: in-process memory; addresses past the window are swept on each check.
|
||||
const seen = new Map<string, number[]>();
|
||||
const allow = (ip: string, now = Date.now()) => {
|
||||
@@ -103,6 +111,8 @@ export function createProbe(opts: ProbeOptions = {}): Server {
|
||||
if (req.method === 'GET' && req.url === '/healthz') return send(res, 200, { ok: true });
|
||||
if (req.method !== 'POST' || req.url !== '/v1/check')
|
||||
return send(res, 404, { error: 'not found' });
|
||||
if (key && !sameKey(req.headers.authorization ?? '', `Bearer ${key}`))
|
||||
return send(res, 401, { error: 'this probe needs its key' });
|
||||
const ip = clientIp(req, trustProxy);
|
||||
void readJson(req)
|
||||
.then(async (body) => {
|
||||
|
||||
@@ -295,6 +295,9 @@ export const systemApi = {
|
||||
api<{ ok: true }>('POST', `/api/system/asterisk/restart?now=${now}`),
|
||||
detectPublicIp: () =>
|
||||
api<{ ip: string }>('POST', '/api/settings/network/detect-public-ip').then((r) => r.ip),
|
||||
/** A new key for the network test probe, shown this once (08 §7 #9). */
|
||||
probeKey: () =>
|
||||
api<{ key: string }>('POST', '/api/settings/network/probe-key').then((r) => r.key),
|
||||
tls: () => api<TlsCert>('GET', '/api/system/tls'),
|
||||
diagnostics: () => api<DiagnosticsDto>('GET', '/api/system/diagnostics'),
|
||||
networkTest: (mode: 'quick' | 'complete') =>
|
||||
|
||||
@@ -505,6 +505,11 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
const [watch, setWatch] = useState(s['network.publicIpWatch']);
|
||||
const [probe, setProbe] = useState(s['network.probeUrl'] ?? '');
|
||||
const detect = useMutation({ mutationFn: systemApi.detectPublicIp, onSuccess: setIp });
|
||||
const qc = useQueryClient();
|
||||
const makeKey = useMutation({
|
||||
mutationFn: systemApi.probeKey,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }),
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
@@ -555,13 +560,61 @@ function NetworkTab({ s }: { s: SettingsDto }) {
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{s['network.probeUrl'] && (
|
||||
<section
|
||||
aria-labelledby="probe-key-h"
|
||||
className="flex flex-col gap-2 rounded-lg border border-line p-3 text-sm"
|
||||
>
|
||||
<h3 id="probe-key-h" className="font-medium">
|
||||
Probe key
|
||||
</h3>
|
||||
{s['network.hasProbeKey'] ? (
|
||||
<p className="text-muted">Set: the probe only answers this server.</p>
|
||||
) : (
|
||||
<p className="text-amber-800 dark:text-amber-200">
|
||||
Not set: anyone who finds your probe can use it. Strongly recommended: generate a key
|
||||
and set it on the probe.
|
||||
</p>
|
||||
)}
|
||||
{makeKey.data ? (
|
||||
<>
|
||||
<p>
|
||||
Set this on the probe (its compose file or service), then restart it. It’s
|
||||
shown only now.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 rounded bg-subtle p-2 font-mono text-xs break-all select-all">
|
||||
PROBE_KEY={makeKey.data}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard.writeText(makeKey.data)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<Button type="button" onClick={() => makeKey.mutate()} disabled={makeKey.isPending}>
|
||||
{s['network.hasProbeKey'] ? 'Generate a new key' : 'Generate key'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
A new probe address drops the key: generate one for the new probe.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" variant="primary" disabled={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
{save.isSuccess && <span className="text-sm text-muted">Saved.</span>}
|
||||
</div>
|
||||
{(save.error ?? detect.error) && <Alert>{(save.error ?? detect.error)!.message}</Alert>}
|
||||
{(save.error ?? detect.error ?? makeKey.error) && (
|
||||
<Alert>{(save.error ?? detect.error ?? makeKey.error)!.message}</Alert>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ services:
|
||||
environment:
|
||||
PORT: '8080'
|
||||
TRUST_PROXY: 'false'
|
||||
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, paste it here, and
|
||||
# the probe only answers your server. Empty: anyone who finds the probe can use it.
|
||||
PROBE_KEY: ''
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: 10m, max-file: '3' }
|
||||
@@ -12,6 +12,9 @@ Wants=network-online.target
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/selfphoneng-probe
|
||||
Environment=PORT=8080
|
||||
# Strongly recommended: SelfPhoneNG → Settings → Network → Generate key, then uncomment and paste
|
||||
# it; the probe then only answers your server.
|
||||
#Environment=PROBE_KEY=
|
||||
# Behind your own HTTPS reverse proxy on this machine that sets X-Forwarded-For: PORT to a local
|
||||
# one, and TRUST_PROXY=true (it then listens on 127.0.0.1 only; HOST changes that).
|
||||
#Environment=TRUST_PROXY=true
|
||||
|
||||
@@ -118,6 +118,14 @@ against someone else, and each address gets a few tests per 10 minutes. Its SIP
|
||||
OPTIONS request; if you run fail2ban or CrowdSec, add the probe's address to their allow list so a
|
||||
test never counts toward a ban.
|
||||
|
||||
**Give the probe a key (strongly recommended).** Without one, anyone who finds your probe can use
|
||||
it to test their own address, and spend its few tests per 10 minutes. After saving the address,
|
||||
click **Generate key** under it, copy the `PROBE_KEY=…` line, and add it to the probe: under
|
||||
`environment:` in `deploy/probe/compose.yml` (`PROBE_KEY: '…'`), or as an `Environment=PROBE_KEY=…`
|
||||
line in the service file (`sudo systemctl edit selfphoneng-probe`). Restart the probe. From then on
|
||||
it only answers this server, and System → Diagnostics stops warning about it. The key is shown only
|
||||
once; a new probe address drops it, so generate one for the new probe.
|
||||
|
||||
## Firewall
|
||||
|
||||
Allow only the ports in the [port table](README.md#ports). All SelfPhoneNG containers use the
|
||||
|
||||
@@ -445,6 +445,7 @@ As built (M11-T6): `routes/reports.ts`. `from`/`to` are company-time-zone days,
|
||||
| GET | `/api/system/warnings` | `{items: [{code, message, link, dismissable?}]}` for the dashboard banner: `restart_required`, `e911_cid_missing` (a trunk exists, no default E911 caller ID), `e911_address_unconfirmed` (that DID has no `e911_address`), `smtp_missing`, `tls_cert` (TLS on and the cert missing or expiring within 14 days, M11-T2), `backup_old` (backups on and today's not made yet though the job had its chance, M11-T3), `directory_check` (12 §4.2, §4.5: the directory unreachable at a sign-in or by `directoryCheck`, or that job refusing to disable most people), `spam_quota` (14 §3, dismissable). "Trunk down" comes from live state in the browser |
|
||||
| POST | `/api/system/warnings/spam_quota/dismiss` | hides `spam_quota` until IPQualityScore's credits run out again (14 §3) |
|
||||
| POST | `/api/settings/network/detect-public-ip` | |
|
||||
| POST | `/api/settings/network/probe-key` | a new key for the network test probe, `{ key }` shown this once, stored encrypted in `network.probeKeyEnc` (never in GET: `network.hasProbeKey`); 400 without a probe address. Diagnostics sends it as `Authorization: Bearer`; a new `network.probeUrl` drops it (08 §7 #9) |
|
||||
| GET | `/api/system/status` | `{ db: ok, ami: connected, asteriskVersion, uptime, trunks: [...], disk: {free, total}, restartRequired, lastGeneratorRun }` |
|
||||
| GET | `/api/system/tls` | the SIP TLS certificate in `TLS_DIR`: `{ok: true, subject, names, notAfter}` or `{ok: false, error}` (M11-T2) |
|
||||
| GET | `/api/system/generated-files` | names + contents of generated files, **passwords masked** (debug view) |
|
||||
|
||||
@@ -117,6 +117,9 @@ how to fix it.
|
||||
works on Gitea's runner, where the job's checkout can't be bind-mounted).
|
||||
`deploy/probe/selfphoneng-probe.service` runs it under systemd with `DynamicUser` and a
|
||||
read-only system. About 45 MB of memory.
|
||||
- With `PROBE_KEY` set the probe only answers `Authorization: Bearer <key>` (401 otherwise,
|
||||
compared in constant time); without it, it answers anyone and logs a warning at start. The key
|
||||
is made in Settings → Network and sent by the api only, never the browser (08 §7 #9).
|
||||
- `POST /v1/check {host, tests}`: the probe tests **only the address the request came from** (the
|
||||
last X-Forwarded-For entry with `TRUST_PROXY=true`, believed only from a proxy on 127.0.0.1
|
||||
or ::1, where the probe then listens by default; 08 §7 #4), so it can't be aimed at anyone else; 20
|
||||
@@ -257,8 +260,10 @@ High.
|
||||
| 6 | Low, privacy | The `voicemail.new` webhook carried the transcript, though webhooks leave the server and 10 §1 says transcripts don't | the webhook has no `transcript` (04 §9) | `voicemail.test.ts` |
|
||||
| 7 | Low | Contrack's saved company token went to any address: Test with another URL, or saving a new URL, sent it there (SMTP, LDAP and OpenID already refused) | the saved token only for the saved address; a new address needs it typed again and clears everyone's own token (11 §4.3) | `contrack.test.ts` |
|
||||
| 8 | Low | The OpenID connect callback wasn't tied to the browser that asked: a planted `sp_oidc` (a sibling subdomain can set it) connected the victim's provider account to the attacker's user | the callback's own session must be the one in `sp_oidc`, else `expired` (12 §3.3) | `oidc.test.ts`, both key types |
|
||||
| 9 | Low | Anyone who found a probe could use it (owner's idea after #4): to test their own address, and spend its per-address limits | an optional key: Settings → Network → Generate key, set as the probe's `PROBE_KEY`; the api sends it as a Bearer token, encrypted at rest, dropped when the probe address changes. Without a key the probe stays open (owner, 2026-10-28) and Diagnostics warns, strongly recommending one (§2.6) | `probe.test.ts`, `diagnostics.test.ts` |
|
||||
|
||||
The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7. The agent had
|
||||
The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7; 9 was the
|
||||
owner's idea. The agent had
|
||||
checked the recording rule and called it sound: it's the same rule as the audio, which §6 also
|
||||
passed.
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ export const SettingsSchemas = {
|
||||
.url({ protocol: /^https?$/ })
|
||||
.nullable()
|
||||
.default(null),
|
||||
/**
|
||||
* The probe's key (its PROBE_KEY), encrypted: made by POST /api/settings/network/probe-key,
|
||||
* sent as a Bearer token, dropped when the address changes (08 §7 #9). Never in GET.
|
||||
*/
|
||||
'network.probeKeyEnc': z.string().nullable().default(null),
|
||||
'network.stunServer': z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
|
||||
@@ -327,10 +332,17 @@ export type SettingsDto = {
|
||||
[
|
||||
K in Exclude<
|
||||
SettingKey,
|
||||
'smtp' | 'sms.webhookToken' | 'contacts.contrack' | 'auth.oidc' | 'auth.ldap' | 'spam'
|
||||
| 'smtp'
|
||||
| 'sms.webhookToken'
|
||||
| 'contacts.contrack'
|
||||
| 'auth.oidc'
|
||||
| 'auth.ldap'
|
||||
| 'spam'
|
||||
| 'network.probeKeyEnc'
|
||||
>
|
||||
]: SettingValue<K>;
|
||||
} & {
|
||||
'network.hasProbeKey': boolean;
|
||||
smtp: (Omit<SmtpInput, 'password'> & { hasPassword: boolean }) | null;
|
||||
'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
|
||||
spam: SpamSettingsDto;
|
||||
|
||||
Reference in new issue
Block a user