Files
hop.noname.company/apps/server/test/network.test.js

488 lines
16 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { io } from 'socket.io-client';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { Administration } from '../src/administration.js';
const actorHeaders = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR' };
const headers = (ip = '8.8.8.8', country = 'KR') => ({
'cf-connecting-ip': ip,
...(country ? { 'cf-ipcountry': country } : {}),
});
async function login(f, email = 'hello@noname.company') {
const c = await f.create();
f.service.db
.insert(s.users)
.values({
id: randomUUID(),
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200, response.body);
c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return c;
}
async function patch(f, admin, body) {
const response = await f.call(
admin,
'/admin/settings',
'PATCH',
{ revision: f.service.admin.state.revision, ...body },
undefined,
actorHeaders,
);
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
async function autoFixture(settings = {}) {
const f = await fixture(),
admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
await patch(f, admin, {
autoBlockEnabled: true,
autoLoginFailures: 3,
autoRateViolations: 3,
...settings,
});
return { f, admin, guest };
}
test(
'existing saved settings retain blacklist rules while new whitelist and automatic protection default to empty/off',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const legacy = {
revision: 4,
uploadsPaused: false,
maxConcurrentUploads: 3,
totalStorageLimitMb: 12000,
blockedCountries: ['JP'],
blockedIps: ['1.1.1.0/24'],
};
f.service.db
.insert(s.adminSettings)
.values({ name: 'controls', value: JSON.stringify(legacy), updatedAt: Date.now() })
.run();
const restored = new Administration(f.service);
assert.equal(restored.state.revision, 4);
assert.deepEqual(restored.state.blockedCountries, ['JP']);
assert.equal(restored.state.blacklistEnabled, true);
assert.equal(restored.state.autoBlockEnabled, false);
assert.deepEqual(restored.state.allowedCountries, []);
assert.deepEqual(restored.state.allowedIps, []);
} finally {
await f.close();
}
},
);
test(
'whitelist is an OR of countries and IP ranges, excludes unknown origins, preserves blacklist priority and prevents operator lockout',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
await patch(f, admin, {
allowedCountries: ['KR'],
allowedIps: ['1.1.1.0/24', '2001:db8::/32'],
blockedIps: ['1.1.1.5'],
});
for (const [ip, country, status] of [
['8.8.8.8', 'KR', 200],
['1.1.1.6', 'JP', 200],
['9.9.9.9', 'JP', 403],
['1.1.1.5', 'JP', 403],
['9.9.9.9', null, 403],
])
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers(ip, country)))
.statusCode,
status,
);
assert.equal(f.service.admin.policyDenied({ ip: '2001:db8::123', countryCode: null }), false);
assert.equal(f.service.admin.policyDenied({ ip: '2001:db9::123', countryCode: null }), true);
const denied = await f.call(
admin,
'/admin/settings',
'PATCH',
{ revision: f.service.admin.state.revision, allowedCountries: ['JP'], allowedIps: [] },
undefined,
actorHeaders,
);
assert.equal(denied.json().code, 'ADMIN_SELF_BLOCK');
assert.deepEqual(f.service.admin.state.allowedCountries, ['KR']);
assert.equal(
(await f.call(null, '/health')).statusCode,
200,
'local container health checks survive a country-only whitelist',
);
assert.equal(
(await f.call(guest, '/health', 'GET', undefined, undefined, headers('9.9.9.9', 'JP')))
.statusCode,
403,
'proxied public health requests still follow the whitelist',
);
await patch(f, admin, { blacklistEnabled: false });
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.5', 'JP')))
.statusCode,
200,
);
await patch(f, admin, { allowedCountries: [], allowedIps: [] });
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('9.9.9.9', null)))
.statusCode,
200,
);
const invalid = await f.call(
admin,
'/admin/network-rules/validate',
'POST',
{ kind: 'ip', value: '1.1.1.999/24' },
undefined,
actorHeaders,
);
assert.equal(invalid.statusCode, 400);
const normalized = await f.call(
admin,
'/admin/network-rules/validate',
'POST',
{ kind: 'country', value: 'jp' },
undefined,
actorHeaders,
);
assert.deepEqual(normalized.json(), { kind: 'country', value: 'JP' });
assert.equal((await f.call(guest, '/admin/automatic-blocks')).statusCode, 401);
assert.equal(
(
await f.call(guest, '/admin/network-rules/validate', 'POST', {
kind: 'country',
value: 'KR',
})
).statusCode,
401,
);
} finally {
await f.close();
}
},
);
test(
'activating a whitelist disconnects existing excluded WebSockets and denies a new handshake',
{ timeout: 20000 },
async () => {
const f = await fixture();
let socket;
try {
const admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
socket = io(f.url, {
transports: ['websocket'],
auth: { csrfToken: guest.csrf },
extraHeaders: {
Origin: 'http://hop.test',
Cookie: guest.cookie,
...headers('1.1.1.1', 'JP'),
},
reconnection: false,
});
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
const disconnected = new Promise((resolve) => socket.once('disconnect', resolve));
await patch(f, admin, { allowedCountries: ['KR'] });
await disconnected;
assert.equal(socket.connected, false);
const failed = new Promise((resolve) => socket.once('connect_error', resolve));
socket.connect();
await failed;
assert.equal(socket.connected, false);
} finally {
socket?.disconnect();
await f.close();
}
},
);
test(
'automatic login bans persist, can be recovered by an existing verified admin, and unblock/expiry restore access',
{ timeout: 20000 },
async () => {
const { f, admin, guest } = await autoFixture();
try {
for (let i = 0; i < 3; i++) {
const response = await f.call(
guest,
'/auth/login',
'POST',
{ email: 'missing@hop.test', password: 'incorrect' },
undefined,
headers(),
);
assert.equal(response.json().code, 'LOGIN_FAILED');
}
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
403,
);
assert.equal(new Administration(f.service).automatic.has('8.8.8.8'), true);
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers('1.1.1.1')))
.statusCode,
200,
);
assert.equal(
(await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode,
200,
'admin recovery session remains usable',
);
const list = (
await f.call(
admin,
'/admin/automatic-blocks?q=8.8',
'GET',
undefined,
undefined,
actorHeaders,
)
).json();
assert.equal(list.total, 1);
assert.equal(list.items[0].reason, 'login-failures');
const removal = await f.call(
admin,
'/admin/automatic-blocks/remove',
'POST',
{ ips: ['8.8.8.8'] },
undefined,
actorHeaders,
);
assert.equal(removal.statusCode, 200);
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
200,
);
const fake = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() };
for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(fake, 'login');
f.service.admin.automatic.active.get('8.8.8.8').expiresAt = Date.now() - 1;
f.service.db
.update(s.automaticBlocks)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
200,
);
f.service.admin.automatic.prune();
assert.equal(f.service.db.select().from(s.automaticBlocks).all().length, 0);
await patch(f, admin, { blockedIps: ['8.8.8.8'] });
assert.equal(
(await f.call(admin, '/admin/summary', 'GET', undefined, undefined, headers())).statusCode,
403,
'admin sessions never bypass manual blacklist',
);
} finally {
await f.close();
}
},
);
test(
'repeated file attempts and declared byte budgets cover idempotent retries and multiple guest sessions on the same IP',
{ timeout: 20000 },
async () => {
const { f, admin, guest } = await autoFixture({ autoUploadRequests: 3 });
try {
const receiver = await f.create();
await f.space(guest, receiver);
const input = {
targetDeviceId: receiver.deviceId,
kind: 'file',
count: 1,
byteSize: 4,
files: [{ name: 'a.txt', size: 4, type: 'text/plain' }],
},
key = randomUUID();
const first = await f.call(guest, '/transfer-requests', 'POST', input, key, headers());
assert.equal(first.statusCode, 200, first.body);
for (let i = 0; i < 4; i++)
assert.equal(
(await f.call(guest, '/transfer-requests', 'POST', input, key, headers())).statusCode,
200,
);
assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|upload').count, 1);
await f.call(guest, '/transfer-requests', 'POST', input, randomUUID(), headers());
const other = await f.create();
await f.call(other, '/transfer-requests', 'POST', input, randomUUID(), headers());
const blocked = await f.call(
other,
'/transfer-requests',
'POST',
input,
randomUUID(),
headers(),
);
assert.equal(blocked.statusCode, 403);
assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-requests');
await f.call(
admin,
'/admin/automatic-blocks/remove',
'POST',
{ ips: ['8.8.8.8'] },
undefined,
actorHeaders,
);
await patch(f, admin, { autoUploadRequests: 30, autoUploadBytes: 1000000 });
const huge = {
...input,
byteSize: 1000001,
files: [{ name: 'a.mp4', size: 1000001, type: 'video/mp4' }],
};
const volume = await f.call(
other,
'/transfer-requests',
'POST',
huge,
randomUUID(),
headers(),
);
assert.equal(volume.statusCode, 403);
assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume');
assert.equal(f.service.storage.fileRows(first.json().requestId).length, 1);
} finally {
await f.close();
}
},
);
test(
'automatic rate protection counts deliberate limit violations but ignores normal chunk requests and untrusted origin headers',
{ timeout: 20000 },
async () => {
const { f, guest } = await autoFixture();
try {
for (let i = 0; i < 240; i++) f.service.limiter.hit('api-ip:8.8.8.8', 240);
for (let i = 0; i < 3; i++)
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
429,
);
assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'rate-violations');
const fake = { raw: { socket: { remoteAddress: '10.0.0.2' } }, headers: headers('1.1.1.1') };
for (let i = 0; i < 4; i++)
assert.equal(f.service.admin.automatic.observe(fake, 'login'), false);
assert.equal(f.service.admin.automatic.has('1.1.1.1'), false);
for (let i = 0; i < 6000; i++) f.service.limiter.hit('chunk-ip:9.9.9.9', 6000);
for (let i = 0; i < 4; i++)
assert.equal(
(
await f.call(
guest,
`/uploads/resumable/${randomUUID()}`,
'PUT',
Buffer.from('x'),
undefined,
{ 'content-type': 'application/octet-stream', ...headers('9.9.9.9') },
)
).statusCode,
429,
);
assert.equal(f.service.admin.automatic.has('9.9.9.9'), false);
} finally {
await f.close();
}
},
);
test(
'signed-in vault file requests share the same IP byte budget and deduplicate retry keys',
{ timeout: 20000 },
async () => {
const { f, guest } = await autoFixture({ autoUploadBytes: 1000000 });
try {
const user = await login(f, 'user@hop.test'),
key = randomUUID(),
body = { kind: 'file', files: [{ name: 'vault.txt', size: 600000, type: 'text/plain' }] };
const first = await f.call(user, '/vault', 'POST', body, key, headers());
assert.equal(first.statusCode, 200, first.body);
assert.equal((await f.call(user, '/vault', 'POST', body, key, headers())).statusCode, 200);
const second = await f.call(
user,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'second.txt', size: 500000, type: 'text/plain' }] },
randomUUID(),
headers(),
);
assert.equal(second.statusCode, 403);
assert.equal(f.service.admin.automatic.active.get('8.8.8.8').reason, 'file-volume');
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
403,
);
} finally {
await f.close();
}
},
);
test(
'successful login resets only login failures, automatic disable preserves recoverable entries, and reenabling applies them',
{ timeout: 20000 },
async () => {
const { f, admin, guest } = await autoFixture();
try {
await login(f, 'success@hop.test');
const req = { raw: { socket: { remoteAddress: '127.0.0.1' } }, headers: headers() };
f.service.admin.automatic.observe(req, 'login');
f.service.admin.automatic.observe(req, 'rate');
assert.equal(
(
await f.call(
guest,
'/auth/login',
'POST',
{ email: 'success@hop.test', password: '12345678' },
undefined,
headers(),
)
).statusCode,
200,
);
assert.equal(f.service.admin.automatic.counters.has('8.8.8.8|login'), false);
assert.equal(f.service.admin.automatic.counters.get('8.8.8.8|rate').count, 1);
for (let i = 0; i < 3; i++) f.service.admin.automatic.observe(req, 'login');
await patch(f, admin, { autoBlockEnabled: false });
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
200,
);
assert.equal(f.service.admin.automatic.has('8.8.8.8'), true);
await patch(f, admin, { autoBlockEnabled: true });
assert.equal(
(await f.call(guest, '/device/me', 'GET', undefined, undefined, headers())).statusCode,
403,
);
} finally {
await f.close();
}
},
);