mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 14:18:02 +00:00
83b3d932e5
* feat: add Apprise as a fourth notification channel Support keyed and stateless Apprise endpoints with secret-safe public DTOs, fail-closed malformed config, and mode-specific Settings UI. Docs and screenshots updated for four-channel Channels and routing. * fix: harden Apprise secrets at rest and preserve-on-write saves Encrypt Apprise endpoint and config with CryptoService so a downgrade cannot leak via SELECT *. Align channel and routing saves so blank destination fields omit config on same-mode URL edits, enforce keyed notify IDs, and keep secrets_redacted truthful. * fix: harden Apprise route type changes and mixed-version config UI Require a raw channel_url when switching notification-route types so ciphertext cannot strand under Discord/Slack/webhook. Default missing remote apprise status, replace Channels state on node switch, and exercise the production config-column migrator. * fix: tolerate stub fleet configuration payloads without agents Normalize remote Apprise agent status only when notifications.agents is present so successful Pilot/stub fetches stay online instead of throwing into the offline catch path. * fix: correct TypeScript in configuration normalize tests * fix: ignore stale Channels agent bodies after node switch Compare the active node after response JSON parsing so a slow body cannot overwrite the newly selected node's channel state. * fix: isolate corrupt Apprise crypto and keep keyed Tags visible Decrypt failures on one Apprise row no longer 500 agent/route lists or suppress sibling channel dispatch. Treat public /notify/<redacted> as keyed so Tags remain editable after reload.
137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
/**
|
|
* Regression guard for the `conditionalJsonParser` remote-proxy bypass.
|
|
*
|
|
* When a request targets a remote node via `x-node-id` and the path is NOT in
|
|
* `PROXY_EXEMPT_PREFIXES`, the JSON parser must leave the request stream
|
|
* untouched so `http-proxy` can pipe the raw body to the upstream Sencho
|
|
* instance. If the parser runs, `req.pipe(proxyReq)` errors with
|
|
* `ERR_HTTP_STREAM_WRITE_AFTER_END` and the remote never sees the body.
|
|
*
|
|
* This test spins up a tiny HTTP echo server, seeds a remote node pointing at
|
|
* it, and POSTs a JSON body through the proxy. The echo server asserts the
|
|
* bytes arrived intact. A second case confirms that exempt paths are handled
|
|
* locally (upstream receives nothing).
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import http from 'http';
|
|
import type { AddressInfo } from 'net';
|
|
import jwt from 'jsonwebtoken';
|
|
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
|
|
|
describe('conditionalJsonParser remote-proxy bypass', () => {
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let upstream: http.Server;
|
|
let upstreamUrl: string;
|
|
let lastUpstreamBody: Buffer | null = null;
|
|
let lastUpstreamAuth: string | null = null;
|
|
let authHeader: string;
|
|
let remoteNodeId: number;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
|
|
upstream = http.createServer((req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c: Buffer) => chunks.push(c));
|
|
req.on('end', () => {
|
|
lastUpstreamBody = Buffer.concat(chunks);
|
|
lastUpstreamAuth = (req.headers['authorization'] as string | undefined) ?? null;
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end('{"ok":true}');
|
|
});
|
|
req.on('error', () => {
|
|
if (!res.headersSent) {
|
|
res.statusCode = 500;
|
|
res.end();
|
|
}
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
|
|
const addr = upstream.address() as AddressInfo;
|
|
upstreamUrl = `http://127.0.0.1:${addr.port}`;
|
|
|
|
({ app } = await import('../index'));
|
|
|
|
const { DatabaseService } = await import('../services/DatabaseService');
|
|
remoteNodeId = DatabaseService.getInstance().addNode({
|
|
name: 'bypass-test-remote',
|
|
type: 'remote',
|
|
compose_dir: '/tmp',
|
|
is_default: false,
|
|
api_url: upstreamUrl,
|
|
api_token: 'bypass-test-token',
|
|
});
|
|
|
|
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
|
authHeader = `Bearer ${token}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
it('forwards the raw request body to the remote for proxy-eligible paths', async () => {
|
|
lastUpstreamBody = null;
|
|
lastUpstreamAuth = null;
|
|
|
|
const payload = { name: 'parser-bypass-stack', content: 'services:\n web:\n image: nginx' };
|
|
|
|
const res = await request(app)
|
|
.post('/api/stacks')
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId))
|
|
.set('Content-Type', 'application/json')
|
|
.send(payload);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(lastUpstreamBody).not.toBeNull();
|
|
expect(lastUpstreamBody!.length).toBeGreaterThan(0);
|
|
const parsed = JSON.parse(lastUpstreamBody!.toString('utf-8'));
|
|
expect(parsed).toEqual(payload);
|
|
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
|
|
});
|
|
|
|
it('handles proxy-exempt paths locally (upstream receives nothing)', async () => {
|
|
lastUpstreamBody = null;
|
|
lastUpstreamAuth = null;
|
|
|
|
const res = await request(app)
|
|
.get(`/api/nodes/${remoteNodeId}`)
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId));
|
|
|
|
expect(lastUpstreamBody).toBeNull();
|
|
expect(lastUpstreamAuth).toBeNull();
|
|
expect([200, 404]).toContain(res.status);
|
|
});
|
|
|
|
it('forwards Apprise agent config bodies intact to the remote', async () => {
|
|
lastUpstreamBody = null;
|
|
lastUpstreamAuth = null;
|
|
|
|
const payload = {
|
|
type: 'apprise',
|
|
url: 'http://apprise.local/notify',
|
|
enabled: true,
|
|
config: { urls: 'discord://webhook-id/webhook-token?token=query-secret' },
|
|
};
|
|
|
|
const res = await request(app)
|
|
.post('/api/agents')
|
|
.set('Authorization', authHeader)
|
|
.set('x-node-id', String(remoteNodeId))
|
|
.set('Content-Type', 'application/json')
|
|
.send(payload);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(lastUpstreamBody).not.toBeNull();
|
|
expect(JSON.parse(lastUpstreamBody!.toString('utf-8'))).toEqual(payload);
|
|
expect(lastUpstreamAuth).toBe('Bearer bypass-test-token');
|
|
expect(JSON.stringify(res.body)).not.toContain('query-secret');
|
|
});
|
|
});
|