@danieltmn/tamnora 0.25.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +13 -25
- package/SECURITY.md +3 -4
- package/harness/packages/cli/src/cli.js +0 -3
- package/harness/packages/cli/src/commands/bridge.js +0 -1
- package/harness/packages/cli/src/commands/repl.js +3 -3
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +1 -1
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/cli/src/lib/util.js +2 -2
- package/harness/packages/cli/src/tui/app.js +2 -2
- package/harness/packages/cli/test/tunnel.test.js +5 -32
- package/harness/packages/core/src/agents/index.js +13 -41
- package/harness/packages/core/src/config/app.js +0 -4
- package/harness/packages/core/src/config/home.js +1 -20
- package/harness/packages/core/src/config/index.js +5 -8
- package/harness/packages/core/src/extensions/load.js +22 -0
- package/harness/packages/core/src/frontmatter.js +32 -0
- package/harness/packages/core/src/hooks/index.js +2 -16
- package/harness/packages/core/src/index.js +2 -14
- package/harness/packages/core/src/plugins/index.js +2 -16
- package/harness/packages/core/src/procs/project.js +68 -0
- package/harness/packages/core/src/procs/static.js +17 -0
- package/harness/packages/core/src/rules/index.js +8 -27
- package/harness/packages/core/src/skills/index.js +8 -19
- package/harness/packages/core/src/storage/sqlite.js +595 -690
- package/harness/packages/core/src/tunnel.js +9 -147
- package/harness/packages/core/test/home.test.js +2 -35
- package/harness/packages/core/test/procs.test.js +3 -64
- package/harness/packages/core/test/storage.test.js +1 -12
- package/harness/packages/server/public/app.js +2 -19
- package/harness/packages/server/public/appearance.js +2 -56
- package/harness/packages/server/public/boot.js +2 -2
- package/harness/packages/server/public/cards.js +2 -21
- package/harness/packages/server/public/chat.js +0 -16
- package/harness/packages/server/public/chatFormat.js +0 -11
- package/harness/packages/server/public/chrome.js +2 -5
- package/harness/packages/server/public/commands.js +1 -2
- package/harness/packages/server/public/explorer.js +7 -10
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +1228 -1326
- package/harness/packages/server/public/landing/boot.js +2 -3
- package/harness/packages/server/public/landing/index.html +3 -367
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/settings.js +1255 -1322
- package/harness/packages/server/public/settingsNav.js +0 -1
- package/harness/packages/server/public/styles.css +2341 -2867
- package/harness/packages/server/public/system.js +356 -495
- package/harness/packages/server/public/voice.js +6 -22
- package/harness/packages/server/src/auth.js +4 -15
- package/harness/packages/server/src/cli.js +1 -1
- package/harness/packages/server/src/lib/agents.js +13 -27
- package/harness/packages/server/src/lib/appearance.js +3 -22
- package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
- package/harness/packages/server/src/lib/definitions.js +40 -0
- package/harness/packages/server/src/lib/skills.js +14 -21
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/workspace.js +9 -11
- package/harness/packages/server/src/routes/chat.js +0 -43
- package/harness/packages/server/src/routes/preview.js +5 -22
- package/harness/packages/server/src/routes/procs.js +2 -4
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/server.js +1 -5
- package/harness/packages/server/test/appearance-route.test.js +3 -3
- package/harness/packages/server/test/appearance.test.js +12 -38
- package/harness/packages/server/test/chatFormat.test.js +0 -8
- package/harness/packages/server/test/consoleTabs.test.js +4 -4
- package/harness/packages/server/test/ops.test.js +8 -56
- package/harness/packages/server/test/preview.test.js +2 -42
- package/harness/packages/server/test/security.test.js +194 -370
- package/harness/packages/server/test/settingsNav.test.js +2 -2
- package/harness/packages/server/test/tts.test.js +4 -4
- package/harness/tamnora.example.json +1 -1
- package/package.json +2 -2
- package/harness/packages/cli/src/commands/eval.js +0 -186
- package/harness/packages/core/src/eval/dataset.js +0 -47
- package/harness/packages/core/src/eval/judge.js +0 -39
- package/harness/packages/core/src/eval/report.js +0 -48
- package/harness/packages/core/src/eval/runTask.js +0 -62
- package/harness/packages/core/src/eval/runner.js +0 -153
- package/harness/packages/core/src/eval/scorers.js +0 -217
- package/harness/packages/core/src/eval/task.js +0 -59
- package/harness/packages/core/src/procs/detect.js +0 -149
- package/harness/packages/core/test/eval.test.js +0 -144
- package/harness/packages/server/public/icons (1).zip +0 -0
- package/harness/packages/server/src/lib/advisor.js +0 -198
- package/harness/packages/server/src/routes/advisor.js +0 -79
- package/harness/packages/server/src/routes/eval.js +0 -100
- package/harness/packages/server/test/advisor.test.js +0 -249
- package/harness/scripts/example-dataset.jsonl +0 -4
|
@@ -1,370 +1,194 @@
|
|
|
1
|
-
import { test } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import { mkdtemp
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { createTamnoraServer } from '../src/index.js';
|
|
8
|
-
import { setHome, ensureApp, writeApp, makeUser
|
|
9
|
-
import { isLoopback, localOk } from '../src/lib/workspace.js';
|
|
10
|
-
import { loginClear } from '../src/auth.js';
|
|
11
|
-
|
|
12
|
-
const fakeProvider = {
|
|
13
|
-
id: 'fake',
|
|
14
|
-
async streamChat(_config, { onEvent }) {
|
|
15
|
-
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
16
|
-
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
async function startServer(overrides = {}) {
|
|
21
|
-
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-sec-')));
|
|
22
|
-
setHome(cwd);
|
|
23
|
-
const instance = await createTamnoraServer({
|
|
24
|
-
cwd,
|
|
25
|
-
dbPath: path.join(cwd, 'tamnora.db'),
|
|
26
|
-
loadMcp: false,
|
|
27
|
-
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
28
|
-
...overrides,
|
|
29
|
-
});
|
|
30
|
-
const info = await instance.listen({ port: 0 });
|
|
31
|
-
return { instance, base: info.url, port: info.port, cwd, home: cwd };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
);
|
|
82
|
-
assert.
|
|
83
|
-
} finally {
|
|
84
|
-
|
|
85
|
-
await instance.close();
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const info = await instance.listen({ port: 0 });
|
|
196
|
-
const base = info.url;
|
|
197
|
-
try {
|
|
198
|
-
const attempt = (xff) =>
|
|
199
|
-
fetch(`${base}/api/auth/login`, {
|
|
200
|
-
method: 'POST',
|
|
201
|
-
headers: { 'Content-Type': 'application/json', ...(xff ? { 'x-forwarded-for': xff } : {}) },
|
|
202
|
-
body: JSON.stringify({ username: 'locktest', password: 'mala' }),
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
for (let i = 0; i < 5; i++) {
|
|
206
|
-
await attempt(i % 2 ? '203.0.113.1' : '203.0.113.2');
|
|
207
|
-
}
|
|
208
|
-
const lockedPlain = await attempt();
|
|
209
|
-
assert.equal(lockedPlain.status, 429);
|
|
210
|
-
|
|
211
|
-
const lockedSpoof = await attempt('198.51.100.7');
|
|
212
|
-
assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
|
|
213
|
-
} finally {
|
|
214
|
-
loginClear({ socket: { remoteAddress: '127.0.0.1' } });
|
|
215
|
-
await instance.close();
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test('la cookie va con Secure cuando hay tunel y el Host es publico', async () => {
|
|
220
|
-
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-cookie-'));
|
|
221
|
-
setHome(cwd);
|
|
222
|
-
const app = ensureApp();
|
|
223
|
-
app.user = makeUser('admin', 'secreto123');
|
|
224
|
-
writeApp(app);
|
|
225
|
-
|
|
226
|
-
const instance = await createTamnoraServer({
|
|
227
|
-
cwd,
|
|
228
|
-
dbPath: path.join(cwd, 'tamnora.db'),
|
|
229
|
-
loadMcp: false,
|
|
230
|
-
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
231
|
-
});
|
|
232
|
-
const info = await instance.listen({ port: 0 });
|
|
233
|
-
try {
|
|
234
|
-
await fakeTunnel();
|
|
235
|
-
|
|
236
|
-
const rawLogin = (host) =>
|
|
237
|
-
new Promise((resolve, reject) => {
|
|
238
|
-
const body = JSON.stringify({ username: 'admin', password: 'secreto123' });
|
|
239
|
-
const req = http.request(
|
|
240
|
-
{ host: '127.0.0.1', port: info.port, path: '/api/auth/login', method: 'POST', headers: { Host: host, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
|
|
241
|
-
(res) => {
|
|
242
|
-
let out = '';
|
|
243
|
-
res.on('data', (c) => (out += c));
|
|
244
|
-
res.on('end', () => resolve({ status: res.statusCode, setCookie: res.headers['set-cookie']?.[0] ?? '' }));
|
|
245
|
-
}
|
|
246
|
-
);
|
|
247
|
-
req.on('error', reject);
|
|
248
|
-
req.end(body);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const viaTunnel = await rawLogin('algo.tunnelmole.net');
|
|
252
|
-
assert.match(String(viaTunnel.setCookie), /Secure/, 'por el tunel la cookie es Secure');
|
|
253
|
-
|
|
254
|
-
const local = await rawLogin('localhost:4517');
|
|
255
|
-
assert.ok(!/Secure/i.test(String(local.setCookie)), 'local http no fuerza Secure');
|
|
256
|
-
} finally {
|
|
257
|
-
await clearTunnel();
|
|
258
|
-
await instance.close();
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
/** Request crudo para poder fijar headers que `fetch` no deja (Host, Origin). */
|
|
263
|
-
function rawRequest({ port, path: reqPath, method = 'GET', headers = {}, body = '' }) {
|
|
264
|
-
return new Promise((resolve, reject) => {
|
|
265
|
-
const req = http.request(
|
|
266
|
-
{
|
|
267
|
-
host: '127.0.0.1',
|
|
268
|
-
port,
|
|
269
|
-
path: reqPath,
|
|
270
|
-
method,
|
|
271
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
|
|
272
|
-
},
|
|
273
|
-
(res) => {
|
|
274
|
-
let out = '';
|
|
275
|
-
res.on('data', (c) => (out += c));
|
|
276
|
-
res.on('end', () => resolve({ status: res.statusCode, body: out }));
|
|
277
|
-
}
|
|
278
|
-
);
|
|
279
|
-
req.on('error', reject);
|
|
280
|
-
req.end(body);
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
test('las mutaciones rechazan un Origin ajeno, con o sin auth (CSRF drive-by)', async () => {
|
|
285
|
-
const { instance, port } = await startServer();
|
|
286
|
-
try {
|
|
287
|
-
const shell = (headers) =>
|
|
288
|
-
rawRequest({
|
|
289
|
-
port,
|
|
290
|
-
path: '/api/shell',
|
|
291
|
-
method: 'POST',
|
|
292
|
-
headers,
|
|
293
|
-
body: JSON.stringify({ command: 'echo pwned' }),
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// Una web maliciosa: simple request con Origin ajeno no ejecuta nada.
|
|
297
|
-
const driveBy = await shell({ Origin: 'https://evil.example' });
|
|
298
|
-
assert.equal(driveBy.status, 403, 'Origin ajeno bloqueado');
|
|
299
|
-
|
|
300
|
-
const nullOrigin = await shell({ Origin: 'null' });
|
|
301
|
-
assert.equal(nullOrigin.status, 403, 'Origin: null bloqueado');
|
|
302
|
-
|
|
303
|
-
// Mismo origen (la consola) y clientes sin navegador (CLI) siguen pasando.
|
|
304
|
-
const sameOrigin = await shell({ Origin: `http://127.0.0.1:${port}` });
|
|
305
|
-
assert.equal(sameOrigin.status, 200, 'mismo origen permitido');
|
|
306
|
-
assert.match(sameOrigin.body, /pwned/);
|
|
307
|
-
|
|
308
|
-
const noOrigin = await shell({});
|
|
309
|
-
assert.equal(noOrigin.status, 200, 'cliente sin Origin (no navegador) permitido');
|
|
310
|
-
} finally {
|
|
311
|
-
await instance.close();
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
test('el Host de un dominio ajeno se rechaza (DNS rebinding)', async () => {
|
|
316
|
-
const { instance, port } = await startServer();
|
|
317
|
-
try {
|
|
318
|
-
const health = (host) => rawRequest({ port, path: '/api/health', headers: { Host: host } });
|
|
319
|
-
|
|
320
|
-
const rebinding = await health('evil.example:8080');
|
|
321
|
-
assert.equal(rebinding.status, 403, 'Host de un dominio ajeno bloqueado');
|
|
322
|
-
|
|
323
|
-
const local = await health('127.0.0.1');
|
|
324
|
-
assert.equal(local.status, 200, 'IP literal permitida');
|
|
325
|
-
|
|
326
|
-
const named = await health('mi-pc.tailnet.ts.net');
|
|
327
|
-
assert.equal(named.status, 200, 'dominio de tailscale permitido');
|
|
328
|
-
|
|
329
|
-
const viaTunnelHost = await health('abc.tunnelmole.net');
|
|
330
|
-
assert.equal(viaTunnelHost.status, 200, 'dominio del tunel permitido');
|
|
331
|
-
} finally {
|
|
332
|
-
await instance.close();
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test('el lockout de login no se esquiva rotando el username', async () => {
|
|
337
|
-
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock2-'));
|
|
338
|
-
setHome(cwd);
|
|
339
|
-
const app = ensureApp();
|
|
340
|
-
app.user = makeUser('locktest2', 'secreto123');
|
|
341
|
-
writeApp(app);
|
|
342
|
-
|
|
343
|
-
const instance = await createTamnoraServer({
|
|
344
|
-
cwd,
|
|
345
|
-
dbPath: path.join(cwd, 'tamnora.db'),
|
|
346
|
-
loadMcp: false,
|
|
347
|
-
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
348
|
-
});
|
|
349
|
-
const info = await instance.listen({ port: 0 });
|
|
350
|
-
const base = info.url;
|
|
351
|
-
try {
|
|
352
|
-
const attempt = (username) =>
|
|
353
|
-
fetch(`${base}/api/auth/login`, {
|
|
354
|
-
method: 'POST',
|
|
355
|
-
headers: { 'Content-Type': 'application/json' },
|
|
356
|
-
body: JSON.stringify({ username, password: 'mala' }),
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Cada intento con un nombre distinto: el contador es por IP, no por usuario.
|
|
360
|
-
for (let i = 0; i < 5; i++) {
|
|
361
|
-
const res = await attempt(`intruso${i}`);
|
|
362
|
-
assert.equal(res.status, 401, `intento ${i} sin bloquear todavia`);
|
|
363
|
-
}
|
|
364
|
-
const locked = await attempt('locktest2');
|
|
365
|
-
assert.equal(locked.status, 429, 'tras 5 fallos el bloqueo aplica a toda la IP');
|
|
366
|
-
} finally {
|
|
367
|
-
loginClear({ socket: { remoteAddress: '127.0.0.1' } });
|
|
368
|
-
await instance.close();
|
|
369
|
-
}
|
|
370
|
-
});
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
8
|
+
import { setHome, ensureApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
9
|
+
import { isLoopback, localOk } from '../src/lib/workspace.js';
|
|
10
|
+
import { loginClear } from '../src/auth.js';
|
|
11
|
+
|
|
12
|
+
const fakeProvider = {
|
|
13
|
+
id: 'fake',
|
|
14
|
+
async streamChat(_config, { onEvent }) {
|
|
15
|
+
onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
|
|
16
|
+
return { content: 'hola', reasoning: '', toolCalls: [] };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function startServer(overrides = {}) {
|
|
21
|
+
const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-sec-')));
|
|
22
|
+
setHome(cwd);
|
|
23
|
+
const instance = await createTamnoraServer({
|
|
24
|
+
cwd,
|
|
25
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
26
|
+
loadMcp: false,
|
|
27
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
const info = await instance.listen({ port: 0 });
|
|
31
|
+
return { instance, base: info.url, port: info.port, cwd, home: cwd };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('isLoopback solo acepta direcciones de loopback', () => {
|
|
35
|
+
for (const good of ['127.0.0.1', '127.9.9.9', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']) {
|
|
36
|
+
assert.ok(isLoopback(good), good);
|
|
37
|
+
}
|
|
38
|
+
for (const bad of ['192.168.1.5', '::ffff:192.168.1.5', '', null, 'fe80::1']) {
|
|
39
|
+
assert.ok(!isLoopback(bad), String(bad));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('localOk exige loopback en remoteAddress', () => {
|
|
44
|
+
const fake = (remote) => ({ socket: { remoteAddress: remote } });
|
|
45
|
+
assert.ok(localOk(fake('127.0.0.1')));
|
|
46
|
+
assert.ok(!localOk(fake('192.168.1.5')));
|
|
47
|
+
// localAddress (la direccion de bind) ya no engana.
|
|
48
|
+
assert.ok(!localOk({ socket: { remoteAddress: '10.0.0.8', localAddress: '127.0.0.1' } }));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
test('el lockout de login no se esquiva falsificando X-Forwarded-For', async () => {
|
|
53
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock-'));
|
|
54
|
+
setHome(cwd);
|
|
55
|
+
const app = ensureApp();
|
|
56
|
+
app.user = makeUser('locktest', 'secreto123');
|
|
57
|
+
writeApp(app);
|
|
58
|
+
|
|
59
|
+
const instance = await createTamnoraServer({
|
|
60
|
+
cwd,
|
|
61
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
62
|
+
loadMcp: false,
|
|
63
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
64
|
+
});
|
|
65
|
+
const info = await instance.listen({ port: 0 });
|
|
66
|
+
const base = info.url;
|
|
67
|
+
try {
|
|
68
|
+
const attempt = (xff) =>
|
|
69
|
+
fetch(`${base}/api/auth/login`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json', ...(xff ? { 'x-forwarded-for': xff } : {}) },
|
|
72
|
+
body: JSON.stringify({ username: 'locktest', password: 'mala' }),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < 5; i++) {
|
|
76
|
+
await attempt(i % 2 ? '203.0.113.1' : '203.0.113.2');
|
|
77
|
+
}
|
|
78
|
+
const lockedPlain = await attempt();
|
|
79
|
+
assert.equal(lockedPlain.status, 429);
|
|
80
|
+
|
|
81
|
+
const lockedSpoof = await attempt('198.51.100.7');
|
|
82
|
+
assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
|
|
83
|
+
} finally {
|
|
84
|
+
loginClear({ socket: { remoteAddress: '127.0.0.1' } });
|
|
85
|
+
await instance.close();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Request crudo para poder fijar headers que `fetch` no deja (Host, Origin). */
|
|
90
|
+
function rawRequest({ port, path: reqPath, method = 'GET', headers = {}, body = '' }) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const req = http.request(
|
|
93
|
+
{
|
|
94
|
+
host: '127.0.0.1',
|
|
95
|
+
port,
|
|
96
|
+
path: reqPath,
|
|
97
|
+
method,
|
|
98
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
|
|
99
|
+
},
|
|
100
|
+
(res) => {
|
|
101
|
+
let out = '';
|
|
102
|
+
res.on('data', (c) => (out += c));
|
|
103
|
+
res.on('end', () => resolve({ status: res.statusCode, body: out }));
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
req.on('error', reject);
|
|
107
|
+
req.end(body);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test('las mutaciones rechazan un Origin ajeno, con o sin auth (CSRF drive-by)', async () => {
|
|
112
|
+
const { instance, port } = await startServer();
|
|
113
|
+
try {
|
|
114
|
+
const shell = (headers) =>
|
|
115
|
+
rawRequest({
|
|
116
|
+
port,
|
|
117
|
+
path: '/api/shell',
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers,
|
|
120
|
+
body: JSON.stringify({ command: 'echo pwned' }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Una web maliciosa: simple request con Origin ajeno no ejecuta nada.
|
|
124
|
+
const driveBy = await shell({ Origin: 'https://evil.example' });
|
|
125
|
+
assert.equal(driveBy.status, 403, 'Origin ajeno bloqueado');
|
|
126
|
+
|
|
127
|
+
const nullOrigin = await shell({ Origin: 'null' });
|
|
128
|
+
assert.equal(nullOrigin.status, 403, 'Origin: null bloqueado');
|
|
129
|
+
|
|
130
|
+
// Mismo origen (la consola) y clientes sin navegador (CLI) siguen pasando.
|
|
131
|
+
const sameOrigin = await shell({ Origin: `http://127.0.0.1:${port}` });
|
|
132
|
+
assert.equal(sameOrigin.status, 200, 'mismo origen permitido');
|
|
133
|
+
assert.match(sameOrigin.body, /pwned/);
|
|
134
|
+
|
|
135
|
+
const noOrigin = await shell({});
|
|
136
|
+
assert.equal(noOrigin.status, 200, 'cliente sin Origin (no navegador) permitido');
|
|
137
|
+
} finally {
|
|
138
|
+
await instance.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('el Host de un dominio ajeno se rechaza (DNS rebinding)', async () => {
|
|
143
|
+
const { instance, port } = await startServer();
|
|
144
|
+
try {
|
|
145
|
+
const health = (host) => rawRequest({ port, path: '/api/health', headers: { Host: host } });
|
|
146
|
+
|
|
147
|
+
const rebinding = await health('evil.example:8080');
|
|
148
|
+
assert.equal(rebinding.status, 403, 'Host de un dominio ajeno bloqueado');
|
|
149
|
+
|
|
150
|
+
const local = await health('127.0.0.1');
|
|
151
|
+
assert.equal(local.status, 200, 'IP literal permitida');
|
|
152
|
+
|
|
153
|
+
const named = await health('mi-pc.tailnet.ts.net');
|
|
154
|
+
assert.equal(named.status, 200, 'dominio de tailscale permitido');
|
|
155
|
+
} finally {
|
|
156
|
+
await instance.close();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('el lockout de login no se esquiva rotando el username', async () => {
|
|
161
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock2-'));
|
|
162
|
+
setHome(cwd);
|
|
163
|
+
const app = ensureApp();
|
|
164
|
+
app.user = makeUser('locktest2', 'secreto123');
|
|
165
|
+
writeApp(app);
|
|
166
|
+
|
|
167
|
+
const instance = await createTamnoraServer({
|
|
168
|
+
cwd,
|
|
169
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
170
|
+
loadMcp: false,
|
|
171
|
+
resolveProvider: () => ({ provider: fakeProvider, config: {} }),
|
|
172
|
+
});
|
|
173
|
+
const info = await instance.listen({ port: 0 });
|
|
174
|
+
const base = info.url;
|
|
175
|
+
try {
|
|
176
|
+
const attempt = (username) =>
|
|
177
|
+
fetch(`${base}/api/auth/login`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ username, password: 'mala' }),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Cada intento con un nombre distinto: el contador es por IP, no por usuario.
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
const res = await attempt(`intruso${i}`);
|
|
186
|
+
assert.equal(res.status, 401, `intento ${i} sin bloquear todavia`);
|
|
187
|
+
}
|
|
188
|
+
const locked = await attempt('locktest2');
|
|
189
|
+
assert.equal(locked.status, 429, 'tras 5 fallos el bloqueo aplica a toda la IP');
|
|
190
|
+
} finally {
|
|
191
|
+
loginClear({ socket: { remoteAddress: '127.0.0.1' } });
|
|
192
|
+
await instance.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
@@ -16,7 +16,7 @@ test('foldText ignora mayusculas y acentos', () => {
|
|
|
16
16
|
test('sin consulta devuelve todas las secciones, en el orden de los grupos', () => {
|
|
17
17
|
assert.deepEqual(ids(filterSettings('')), ids(SETTINGS_SECTIONS));
|
|
18
18
|
assert.deepEqual(ids(filterSettings(' ')), ids(SETTINGS_SECTIONS));
|
|
19
|
-
assert.equal(SETTINGS_SECTIONS.length,
|
|
19
|
+
assert.equal(SETTINGS_SECTIONS.length, 13);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test('busca por nombre sin importar acentos ni mayusculas', () => {
|
|
@@ -27,7 +27,7 @@ test('busca por nombre sin importar acentos ni mayusculas', () => {
|
|
|
27
27
|
|
|
28
28
|
test('el nombre del grupo no tapa a la seccion: "voz" da Voz, no todo "Voz y canales"', () => {
|
|
29
29
|
assert.deepEqual(ids(filterSettings('voz')), ['voz']);
|
|
30
|
-
assert.deepEqual(ids(filterSettings('canales')), ['voz', 'transcripcion', '
|
|
30
|
+
assert.deepEqual(ids(filterSettings('canales')), ['voz', 'transcripcion', 'telegram']);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test('busca por palabra clave y por grupo', () => {
|
|
@@ -80,25 +80,25 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
|
|
|
80
80
|
assert.equal(initial.announce.start, true);
|
|
81
81
|
assert.equal(initial.announce.tools, true);
|
|
82
82
|
assert.equal(initial.announce.end, true);
|
|
83
|
-
assert.equal(initial.announce.
|
|
83
|
+
assert.equal(initial.announce.answer, false);
|
|
84
84
|
|
|
85
85
|
const put = await fetch(`${base}/api/tts/settings`, {
|
|
86
86
|
method: 'PUT',
|
|
87
87
|
headers: { 'Content-Type': 'application/json' },
|
|
88
|
-
body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: {
|
|
88
|
+
body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: { answer: true } }),
|
|
89
89
|
});
|
|
90
90
|
const saved = await put.json();
|
|
91
91
|
assert.equal(saved.enabled, true);
|
|
92
92
|
assert.equal(saved.voice, 'es-AR-ElenaNeural');
|
|
93
93
|
assert.equal(saved.rate, '+100%');
|
|
94
94
|
assert.equal(saved.pitch, '+50Hz');
|
|
95
|
-
assert.equal(saved.announce.
|
|
95
|
+
assert.equal(saved.announce.answer, true);
|
|
96
96
|
|
|
97
97
|
const reread = await (await fetch(`${base}/api/tts/settings`)).json();
|
|
98
98
|
assert.equal(reread.enabled, true);
|
|
99
99
|
assert.equal(reread.rate, '+100%');
|
|
100
100
|
assert.equal(reread.pitch, '+50Hz');
|
|
101
|
-
assert.equal(reread.announce.
|
|
101
|
+
assert.equal(reread.announce.answer, true);
|
|
102
102
|
} finally {
|
|
103
103
|
global.WebSocket = original;
|
|
104
104
|
await instance.close();
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"enabled": true,
|
|
55
55
|
"allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
|
|
56
56
|
},
|
|
57
|
-
"preview": { "provider": "tailscale", "tailscalePort": 8443
|
|
57
|
+
"preview": { "provider": "tailscale", "tailscalePort": 8443 },
|
|
58
58
|
"telegram": { "token": "", "allowedChatIds": [], "transcribeModel": "whisper-1" },
|
|
59
59
|
"agents": { "maxRounds": 6 }
|
|
60
60
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danieltmn/tamnora",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Harness local para IAs: agente CLI-first con TUI
|
|
3
|
+
"version": "0.26.1",
|
|
4
|
+
"description": "Harness local para IAs: agente CLI-first con TUI y consola web. Node puro, sin dependencias.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.25.0",
|
|
7
7
|
"author": "tamnora",
|