488 lines
16 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
);
|