Add network protection and administrator account support
This commit is contained in:
19
apps/server/drizzle/0014_network_protection.sql
Normal file
19
apps/server/drizzle/0014_network_protection.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `automatic_blocks` (
|
||||
`ip` text PRIMARY KEY NOT NULL,
|
||||
`countryCode` text,
|
||||
`reason` text NOT NULL,
|
||||
`createdAt` integer NOT NULL,
|
||||
`expiresAt` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `automatic_block_expiry` ON `automatic_blocks` (`expiresAt`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_activity` (
|
||||
`userId` text PRIMARY KEY NOT NULL,
|
||||
`lastSeenAt` integer NOT NULL,
|
||||
`ip` text,
|
||||
`countryCode` text,
|
||||
`region` text,
|
||||
`city` text,
|
||||
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1792030497381,
|
||||
"tag": "0013_file_upload_contexts",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1792031695946,
|
||||
"tag": "0014_network_protection",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,26 @@ import { statfsSync } from 'node:fs';
|
||||
import { unlink } from 'node:fs/promises';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gt, lt, or, desc, inArray, sql } from 'drizzle-orm';
|
||||
import { and, eq, gt, lt, or, desc, inArray, sql, isNotNull } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { ApiError, fail, normalizeIp } from './security.js';
|
||||
import { visitorRegion } from './ownerInfo.js';
|
||||
import { AutomaticBlocks } from './automaticBlocks.js';
|
||||
|
||||
export const ADMIN_EMAIL = 'hello@noname.company';
|
||||
export function settingsChanges(before, after) {
|
||||
const changes = [];
|
||||
for (const field of Object.keys(fields)) {
|
||||
if (!(field in before) || !(field in after)) continue;
|
||||
if (Array.isArray(after[field])) {
|
||||
const added = after[field].filter((value) => !before[field].includes(value)),
|
||||
removed = before[field].filter((value) => !after[field].includes(value));
|
||||
if (added.length || removed.length) changes.push({ field, added, removed });
|
||||
} else if (before[field] !== after[field])
|
||||
changes.push({ field, before: before[field], after: after[field] });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
function rule(value) {
|
||||
const [address, prefix] = value.includes('/')
|
||||
? ipaddr.parseCIDR(value)
|
||||
@@ -53,6 +67,37 @@ const fields = {
|
||||
)
|
||||
.max(250),
|
||||
blockedIps: z.array(z.string().trim().max(100).refine(validRule)).max(100),
|
||||
blacklistEnabled: z.boolean(),
|
||||
allowedCountries: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.regex(/^[A-Z]{2}$/)
|
||||
.refine((code) => code !== 'XX'),
|
||||
)
|
||||
.max(250),
|
||||
allowedIps: z.array(z.string().trim().max(100).refine(validRule)).max(100),
|
||||
autoBlockEnabled: z.boolean(),
|
||||
autoWindowMinutes: z.number().int().min(1).max(1440),
|
||||
autoLoginFailures: z.number().int().min(3).max(1000),
|
||||
autoRateViolations: z.number().int().min(3).max(1000),
|
||||
autoUploadRequests: z.number().int().min(3).max(10000),
|
||||
autoUploadBytes: z.number().int().min(1000000).max(1e12),
|
||||
autoBlockMinutes: z.number().int().min(1).max(10080),
|
||||
};
|
||||
const networkDefaults = {
|
||||
blacklistEnabled: true,
|
||||
allowedCountries: [],
|
||||
allowedIps: [],
|
||||
autoBlockEnabled: false,
|
||||
autoWindowMinutes: 10,
|
||||
autoLoginFailures: 10,
|
||||
autoRateViolations: 20,
|
||||
autoUploadRequests: 30,
|
||||
autoUploadBytes: 10e9,
|
||||
autoBlockMinutes: 30,
|
||||
};
|
||||
const stateSchema = z.object({ revision: z.number().int().min(0), ...fields }).strict();
|
||||
export const adminPatch = z
|
||||
@@ -61,6 +106,26 @@ export const adminPatch = z
|
||||
.extend({ revision: z.number().int().min(0) })
|
||||
.strict()
|
||||
.refine((body) => Object.keys(body).length > 1, '변경할 설정이 필요합니다.');
|
||||
export const networkRuleInput = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('country'), value: fields.blockedCountries.element }).strict(),
|
||||
z.object({ kind: z.literal('ip'), value: fields.blockedIps.element }).strict(),
|
||||
]);
|
||||
export function normalizeNetworkRule(input) {
|
||||
if (input.kind === 'country') return input;
|
||||
const [address, prefix] = rule(input.value);
|
||||
return {
|
||||
...input,
|
||||
value: input.value.includes('/') ? `${address}/${prefix}` : address.toString(),
|
||||
};
|
||||
}
|
||||
export const automaticBlockRemoval = z
|
||||
.object({
|
||||
ips: z
|
||||
.array(z.string().refine((value) => Boolean(normalizeIp(value))))
|
||||
.min(1)
|
||||
.max(100),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class Administration {
|
||||
constructor(service) {
|
||||
@@ -72,7 +137,7 @@ export class Administration {
|
||||
.where(eq(s.adminSettings.name, 'controls'))
|
||||
.get();
|
||||
this.state = row
|
||||
? stateSchema.parse(JSON.parse(row.value))
|
||||
? stateSchema.parse({ ...networkDefaults, ...JSON.parse(row.value) })
|
||||
: {
|
||||
revision: 0,
|
||||
uploadsPaused: false,
|
||||
@@ -80,12 +145,16 @@ export class Administration {
|
||||
totalStorageLimitMb: service.cfg.totalStorageQuota / 1048576,
|
||||
blockedCountries: [],
|
||||
blockedIps: [],
|
||||
...networkDefaults,
|
||||
};
|
||||
this.automatic = new AutomaticBlocks(service);
|
||||
this.compile();
|
||||
}
|
||||
compile() {
|
||||
this.ipRules = this.state.blockedIps.map(rule);
|
||||
this.countries = new Set(this.state.blockedCountries);
|
||||
this.allowedIpRules = this.state.allowedIps.map(rule);
|
||||
this.allowedCountries = new Set(this.state.allowedCountries);
|
||||
}
|
||||
isAdmin(user) {
|
||||
return Boolean(user?.status === 'active' && user.verifiedAt && user.email === ADMIN_EMAIL);
|
||||
@@ -98,9 +167,32 @@ export class Administration {
|
||||
}
|
||||
blocked(req) {
|
||||
const context = visitorRegion(req, this.service.cfg);
|
||||
const direct = normalizeIp(req.raw.socket.remoteAddress ?? '');
|
||||
if (this.policyDenied({ ...context, ip: context.ip ?? direct })) return true;
|
||||
const token =
|
||||
req.cookies?.hop_account ??
|
||||
/(?:^|;\s*)hop_account=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
|
||||
// Existing verified administrator sessions can recover an automatic IP ban.
|
||||
return (
|
||||
matchesIp(context.ip ?? normalizeIp(req.raw.socket.remoteAddress ?? ''), this.ipRules) ||
|
||||
Boolean(context.countryCode && this.countries.has(context.countryCode))
|
||||
this.state.autoBlockEnabled &&
|
||||
this.automatic.has(context.ip) &&
|
||||
!this.isAdmin(token ? this.service.auth?.session(token)?.user : null)
|
||||
);
|
||||
}
|
||||
policyDenied(context, state = this.state) {
|
||||
const blackIps = state === this.state ? this.ipRules : state.blockedIps.map(rule),
|
||||
whiteIps = state === this.state ? this.allowedIpRules : state.allowedIps.map(rule),
|
||||
blackCountries = state === this.state ? this.countries : new Set(state.blockedCountries),
|
||||
whiteCountries =
|
||||
state === this.state ? this.allowedCountries : new Set(state.allowedCountries);
|
||||
if (
|
||||
state.blacklistEnabled &&
|
||||
(matchesIp(context.ip, blackIps) || blackCountries.has(context.countryCode))
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
Boolean(state.allowedCountries.length || state.allowedIps.length) &&
|
||||
!(matchesIp(context.ip, whiteIps) || whiteCountries.has(context.countryCode))
|
||||
);
|
||||
}
|
||||
assertAllowed(req) {
|
||||
@@ -153,7 +245,7 @@ export class Administration {
|
||||
.insert(s.adminAudit)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
actorUserId: actor.id,
|
||||
actorUserId: actor?.id ?? null,
|
||||
action,
|
||||
details: JSON.stringify(details),
|
||||
createdAt: Date.now(),
|
||||
@@ -169,6 +261,10 @@ export class Administration {
|
||||
);
|
||||
const next = stateSchema.parse({ ...this.state, ...input, revision: this.state.revision + 1 });
|
||||
next.blockedCountries = [...new Set(next.blockedCountries)].sort();
|
||||
next.allowedCountries = [...new Set(next.allowedCountries)].sort();
|
||||
next.allowedIps = [
|
||||
...new Set(next.allowedIps.map((value) => normalizeNetworkRule({ kind: 'ip', value }).value)),
|
||||
].sort();
|
||||
next.blockedIps = [
|
||||
...new Set(
|
||||
next.blockedIps.map((value) => {
|
||||
@@ -180,11 +276,15 @@ export class Administration {
|
||||
const context = visitorRegion(req, this.service.cfg),
|
||||
rules = next.blockedIps.map(rule);
|
||||
if (
|
||||
(context.countryCode && next.blockedCountries.includes(context.countryCode)) ||
|
||||
matchesIp(context.ip, rules) ||
|
||||
matchesIp(normalizeIp(req.raw.socket.remoteAddress ?? ''), rules)
|
||||
this.policyDenied(
|
||||
{ ...context, ip: context.ip ?? normalizeIp(req.raw.socket.remoteAddress ?? '') },
|
||||
next,
|
||||
) ||
|
||||
(next.blacklistEnabled && matchesIp(normalizeIp(req.raw.socket.remoteAddress ?? ''), rules))
|
||||
)
|
||||
fail(400, 'ADMIN_SELF_BLOCK', '현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.');
|
||||
const changes = settingsChanges(this.state, next);
|
||||
if (!changes.length) return { ...this.state };
|
||||
this.service.db.transaction(() => {
|
||||
this.service.db
|
||||
.insert(s.adminSettings)
|
||||
@@ -194,20 +294,27 @@ export class Administration {
|
||||
set: { value: JSON.stringify(next), updatedAt: Date.now() },
|
||||
})
|
||||
.run();
|
||||
this.audit(actor, 'settings-change', {
|
||||
uploadsPaused: next.uploadsPaused,
|
||||
maxConcurrentUploads: next.maxConcurrentUploads,
|
||||
totalStorageLimitMb: next.totalStorageLimitMb,
|
||||
blockedCountries: next.blockedCountries,
|
||||
blockedIps: next.blockedIps,
|
||||
});
|
||||
this.audit(actor, 'settings-change', { changes });
|
||||
});
|
||||
if (
|
||||
Object.keys(networkDefaults).some(
|
||||
(key) => key.startsWith('auto') && next[key] !== this.state[key],
|
||||
)
|
||||
)
|
||||
this.automatic.resetCounters();
|
||||
this.state = next;
|
||||
this.compile();
|
||||
this.disconnectBlocked();
|
||||
return { ...this.state };
|
||||
}
|
||||
disconnectBlocked() {
|
||||
for (const entry of this.inflight.values())
|
||||
if (
|
||||
matchesIp(entry.ip ?? normalizeIp(entry.raw.socket.remoteAddress ?? ''), this.ipRules) ||
|
||||
(entry.countryCode && this.countries.has(entry.countryCode))
|
||||
this.policyDenied({
|
||||
ip: entry.ip ?? normalizeIp(entry.raw.socket.remoteAddress ?? ''),
|
||||
countryCode: entry.countryCode,
|
||||
}) ||
|
||||
(this.state.autoBlockEnabled && this.automatic.has(entry.ip))
|
||||
) {
|
||||
entry.raw.emit('aborted');
|
||||
entry.raw.destroy();
|
||||
@@ -218,7 +325,6 @@ export class Administration {
|
||||
socket.emit('service:blocked');
|
||||
socket.disconnect(true);
|
||||
}
|
||||
return { ...this.state };
|
||||
}
|
||||
summary() {
|
||||
const db = this.service.db;
|
||||
@@ -254,6 +360,9 @@ export class Administration {
|
||||
activeAccounts: Number(accounts.count),
|
||||
activeRequests: this.inflight.size,
|
||||
activeFileWrites: this.service.storage.activeUploadCount,
|
||||
automaticBlockCount: [...this.automatic.active.values()].filter(
|
||||
(row) => row.expiresAt > Date.now(),
|
||||
).length,
|
||||
requests: [...this.inflight.values()].map(({ actor, raw, response, ...value }) => value),
|
||||
};
|
||||
}
|
||||
@@ -347,9 +456,236 @@ export class Administration {
|
||||
.orderBy(desc(s.adminAudit.createdAt), desc(s.adminAudit.id))
|
||||
.limit(50)
|
||||
.all()
|
||||
.map((event) => ({ ...event, details: JSON.parse(event.details) }));
|
||||
.map((event) => this.eventDetails(event));
|
||||
}
|
||||
eventDetails(event) {
|
||||
let details = JSON.parse(event.details);
|
||||
if (event.action === 'settings-change' && !details.changes) {
|
||||
const prior = this.service.db
|
||||
.select()
|
||||
.from(s.adminAudit)
|
||||
.where(
|
||||
and(
|
||||
eq(s.adminAudit.action, 'settings-change'),
|
||||
lt(s.adminAudit.createdAt, event.createdAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(s.adminAudit.createdAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
const previous = prior ? JSON.parse(prior.details) : null;
|
||||
details = {
|
||||
changes: previous && !previous.changes ? settingsChanges(previous, details) : [],
|
||||
legacy: true,
|
||||
};
|
||||
}
|
||||
return { ...event, details };
|
||||
}
|
||||
eventPage(offset = 0) {
|
||||
const rows = this.service.db
|
||||
.select()
|
||||
.from(s.adminAudit)
|
||||
.orderBy(desc(s.adminAudit.createdAt), desc(s.adminAudit.id))
|
||||
.limit(51)
|
||||
.offset(offset)
|
||||
.all();
|
||||
return {
|
||||
events: rows.slice(0, 50).map((row) => this.eventDetails(row)),
|
||||
nextOffset: rows.length > 50 ? offset + 50 : null,
|
||||
};
|
||||
}
|
||||
removeEvents(ids, all = false) {
|
||||
const query = this.service.db.delete(s.adminAudit);
|
||||
if (all) query.run();
|
||||
else query.where(inArray(s.adminAudit.id, ids)).run();
|
||||
return { ok: true };
|
||||
}
|
||||
recordUser(req, authenticatedUserId) {
|
||||
try {
|
||||
this.recordUserContext(req, authenticatedUserId);
|
||||
} catch {
|
||||
req.log?.warn('User activity metadata could not be recorded');
|
||||
}
|
||||
}
|
||||
recordUserContext(req, authenticatedUserId) {
|
||||
const userId =
|
||||
authenticatedUserId ?? this.service.auth.session(req.cookies.hop_account)?.user.id;
|
||||
if (!userId) return;
|
||||
const context = visitorRegion(req, this.service.cfg),
|
||||
old = this.service.db
|
||||
.select()
|
||||
.from(s.userActivity)
|
||||
.where(eq(s.userActivity.userId, userId))
|
||||
.get();
|
||||
if (
|
||||
old &&
|
||||
old.lastSeenAt > Date.now() - 60000 &&
|
||||
['ip', 'countryCode', 'region', 'city'].every((key) => old[key] === context[key])
|
||||
)
|
||||
return;
|
||||
const row = {
|
||||
userId,
|
||||
lastSeenAt: Date.now(),
|
||||
ip: context.ip,
|
||||
countryCode: context.countryCode,
|
||||
region: context.region,
|
||||
city: context.city,
|
||||
};
|
||||
this.service.db
|
||||
.insert(s.userActivity)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: s.userActivity.userId, set: row })
|
||||
.run();
|
||||
}
|
||||
users({ offset = 0, q = '' } = {}) {
|
||||
const rows = this.service.db
|
||||
.select({
|
||||
id: s.users.id,
|
||||
email: s.users.email,
|
||||
name: s.users.displayName,
|
||||
status: s.users.status,
|
||||
verifiedAt: s.users.verifiedAt,
|
||||
createdAt: s.users.createdAt,
|
||||
lastSeenAt: s.userActivity.lastSeenAt,
|
||||
ip: s.userActivity.ip,
|
||||
countryCode: s.userActivity.countryCode,
|
||||
region: s.userActivity.region,
|
||||
city: s.userActivity.city,
|
||||
})
|
||||
.from(s.users)
|
||||
.leftJoin(s.userActivity, eq(s.userActivity.userId, s.users.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(s.users.status, ['pending', 'active', 'suspended']),
|
||||
q ? sql`instr(lower(${s.users.email}),lower(${q})) > 0` : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(s.users.createdAt), desc(s.users.id))
|
||||
.limit(51)
|
||||
.offset(offset)
|
||||
.all();
|
||||
const cutoff = Date.now() - this.service.cfg.auditDays * 86400000;
|
||||
return {
|
||||
users: rows
|
||||
.slice(0, 50)
|
||||
.map((row) =>
|
||||
row.lastSeenAt && row.lastSeenAt < cutoff
|
||||
? { ...row, ip: null, countryCode: null, region: null, city: null }
|
||||
: row,
|
||||
),
|
||||
nextOffset: rows.length > 50 ? offset + 50 : null,
|
||||
};
|
||||
}
|
||||
async resetUserPassword(actor, id) {
|
||||
const user = this.service.auth.user(id);
|
||||
if (!user.email || !['pending', 'active'].includes(user.status))
|
||||
fail(409, 'ACCOUNT_UNAVAILABLE', '이 계정에 재설정 메일을 보낼 수 없습니다.');
|
||||
await this.service.auth.resend(user.email, 'reset', `admin:${actor.id}`);
|
||||
this.audit(actor, 'password-reset-email', { userId: id, email: user.email });
|
||||
return { ok: true };
|
||||
}
|
||||
async deleteUser(actor, id, email) {
|
||||
const user = this.service.auth.user(id),
|
||||
db = this.service.db,
|
||||
now = Date.now();
|
||||
if (id === actor.id || user.email === ADMIN_EMAIL)
|
||||
fail(403, 'ADMIN_ACCOUNT_PROTECTED', '관리자 계정은 여기서 삭제할 수 없습니다.');
|
||||
if (user.status === 'deleted' || !user.email)
|
||||
fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
|
||||
if (email !== user.email)
|
||||
fail(
|
||||
409,
|
||||
'ACCOUNT_CONFIRMATION_CHANGED',
|
||||
'대상 이메일이 일치하지 않습니다. 목록을 다시 확인해 주세요.',
|
||||
);
|
||||
const pending = db
|
||||
.select({ id: s.requests.id })
|
||||
.from(s.requests)
|
||||
.innerJoin(s.batches, eq(s.batches.requestId, s.requests.id))
|
||||
.where(
|
||||
and(
|
||||
or(
|
||||
eq(s.requests.senderUserId, id),
|
||||
eq(s.requests.recipientUserId, id),
|
||||
inArray(
|
||||
s.requests.id,
|
||||
db
|
||||
.select({ id: s.vaultTransfers.requestId })
|
||||
.from(s.vaultTransfers)
|
||||
.where(eq(s.vaultTransfers.userId, id)),
|
||||
),
|
||||
),
|
||||
inArray(s.batches.status, ['waiting', 'uploading']),
|
||||
inArray(s.requests.status, ['pending', 'accepted']),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
db.transaction(() => {
|
||||
db.update(s.users)
|
||||
.set({
|
||||
status: 'deleted',
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
verifiedAt: null,
|
||||
displayName: '삭제된 계정',
|
||||
receivePolicy: 'none',
|
||||
})
|
||||
.where(eq(s.users.id, id))
|
||||
.run();
|
||||
db.update(s.accountSessions)
|
||||
.set({ revokedAt: now })
|
||||
.where(eq(s.accountSessions.userId, id))
|
||||
.run();
|
||||
db.update(s.accountTokens).set({ usedAt: now }).where(eq(s.accountTokens.userId, id)).run();
|
||||
db.update(s.access)
|
||||
.set({ deletedAt: now - this.service.cfg.undoSeconds * 1000 - 1, note: null })
|
||||
.where(eq(s.access.userId, id))
|
||||
.run();
|
||||
db.delete(s.userActivity).where(eq(s.userActivity.userId, id)).run();
|
||||
for (const request of pending) {
|
||||
db.update(s.requests)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq(s.requests.id, request.id))
|
||||
.run();
|
||||
db.update(s.batches)
|
||||
.set({ status: 'failed' })
|
||||
.where(eq(s.batches.requestId, request.id))
|
||||
.run();
|
||||
}
|
||||
this.audit(actor, 'account-delete', { userId: id, email: user.email });
|
||||
});
|
||||
this.service.auth.revoke(id);
|
||||
for (const request of pending) {
|
||||
this.service.storage.release(request.id);
|
||||
this.service.notifyTransfer(request.id);
|
||||
}
|
||||
const owner = this.service.storage.layout.ownerInfo.details(`user-${id}/file`);
|
||||
try {
|
||||
const info = this.service.storage.layout.ownerInfo;
|
||||
if (owner) info.write(owner, info.read(owner).events);
|
||||
} catch {}
|
||||
try {
|
||||
await this.service.storage.cleanup();
|
||||
} catch {}
|
||||
return { ok: true };
|
||||
}
|
||||
cleanup() {
|
||||
this.automatic.prune();
|
||||
this.service.db
|
||||
.update(s.userActivity)
|
||||
.set({ ip: null, countryCode: null, region: null, city: null })
|
||||
.where(
|
||||
and(
|
||||
lt(s.userActivity.lastSeenAt, Date.now() - this.service.cfg.auditDays * 86400000),
|
||||
or(
|
||||
isNotNull(s.userActivity.ip),
|
||||
isNotNull(s.userActivity.countryCode),
|
||||
isNotNull(s.userActivity.region),
|
||||
isNotNull(s.userActivity.city),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
this.service.db
|
||||
.delete(s.adminAudit)
|
||||
.where(lt(s.adminAudit.createdAt, Date.now() - this.service.cfg.auditDays * 86400000))
|
||||
|
||||
@@ -31,7 +31,12 @@ import { openDatabase } from './db/index.js';
|
||||
import { Service, type Guest } from './service.js';
|
||||
import { Presence } from './presence.js';
|
||||
import { visitorRegion } from './ownerInfo.js';
|
||||
import { adminPatch } from './administration.js';
|
||||
import {
|
||||
adminPatch,
|
||||
networkRuleInput,
|
||||
normalizeNetworkRule,
|
||||
automaticBlockRemoval,
|
||||
} from './administration.js';
|
||||
import { ApiError, fail, clientIp, normalizeIp, publicIp, hash } from './security.js';
|
||||
import { CHUNK_BYTES } from './resumable.js';
|
||||
|
||||
@@ -253,7 +258,13 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
|
||||
);
|
||||
service.admin.assertAllowed(req);
|
||||
const localHealth =
|
||||
['GET', 'HEAD'].includes(req.method) &&
|
||||
req.url.split('?')[0] === '/api/health' &&
|
||||
['127.0.0.1', '::1'].includes(normalizeIp(req.raw.socket.remoteAddress ?? '') ?? '') &&
|
||||
!req.headers['cf-connecting-ip'] &&
|
||||
!req.headers['x-forwarded-for'];
|
||||
if (!localHealth) service.admin.assertAllowed(req);
|
||||
if (req.url.startsWith('/api/')) {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
const resumable = req.url.split('?')[0].startsWith('/api/uploads/resumable');
|
||||
@@ -285,9 +296,21 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
const current = service.auth.session(req.cookies.hop_account);
|
||||
service.admin.admit(req, reply, current?.user.id ?? auth(req).id);
|
||||
}
|
||||
service.admin.recordUser(req);
|
||||
}
|
||||
});
|
||||
app.setErrorHandler((err, req, reply) => {
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.code === 'RATE_LIMIT' &&
|
||||
!req.url.startsWith('/api/uploads/resumable')
|
||||
) {
|
||||
try {
|
||||
service.admin.automatic.observe(req, 'rate');
|
||||
} catch {
|
||||
req.log.warn('Automatic network protection could not be recorded');
|
||||
}
|
||||
}
|
||||
let status = 500,
|
||||
code = 'INTERNAL_ERROR',
|
||||
message = '처리 중 오류가 발생했습니다.';
|
||||
@@ -364,8 +387,69 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
);
|
||||
app.get('/api/admin/audit', async (req) => {
|
||||
service.admin.require(req.cookies.hop_account);
|
||||
return { events: service.admin.events() };
|
||||
const { offset } = z
|
||||
.object({ offset: z.coerce.number().int().min(0).max(100000).default(0) })
|
||||
.strict()
|
||||
.parse(req.query);
|
||||
return service.admin.eventPage(offset);
|
||||
});
|
||||
app.post('/api/admin/audit/remove', { bodyLimit: 16384 }, async (req) => {
|
||||
service.admin.require(req.cookies.hop_account);
|
||||
const input = z
|
||||
.union([
|
||||
z.object({ all: z.literal(true) }).strict(),
|
||||
z.object({ ids: z.array(z.uuid()).min(1).max(100) }).strict(),
|
||||
])
|
||||
.parse(req.body);
|
||||
return service.admin.removeEvents('ids' in input ? input.ids : [], 'all' in input);
|
||||
});
|
||||
app.get('/api/admin/users', async (req) => {
|
||||
service.admin.require(req.cookies.hop_account);
|
||||
return service.admin.users(
|
||||
z
|
||||
.object({
|
||||
offset: z.coerce.number().int().min(0).max(100000).default(0),
|
||||
q: z.string().trim().max(254).default(''),
|
||||
})
|
||||
.strict()
|
||||
.parse(req.query),
|
||||
);
|
||||
});
|
||||
app.post('/api/admin/users/:id/password-reset', { bodyLimit: 1024 }, async (req) =>
|
||||
service.admin.resetUserPassword(service.admin.require(req.cookies.hop_account), id(req)),
|
||||
);
|
||||
app.post('/api/admin/users/:id/delete', { bodyLimit: 1024 }, async (req) =>
|
||||
service.admin.deleteUser(
|
||||
service.admin.require(req.cookies.hop_account),
|
||||
id(req),
|
||||
z
|
||||
.object({ email: z.string().max(254) })
|
||||
.strict()
|
||||
.parse(req.body).email,
|
||||
),
|
||||
);
|
||||
app.post('/api/admin/network-rules/validate', { bodyLimit: 1024 }, async (req) => {
|
||||
service.admin.require(req.cookies.hop_account);
|
||||
return normalizeNetworkRule(networkRuleInput.parse(req.body));
|
||||
});
|
||||
app.get('/api/admin/automatic-blocks', async (req) => {
|
||||
service.admin.require(req.cookies.hop_account);
|
||||
return service.admin.automatic.list(
|
||||
z
|
||||
.object({
|
||||
offset: z.coerce.number().int().min(0).max(10000).default(0),
|
||||
q: z.string().trim().max(100).default(''),
|
||||
})
|
||||
.strict()
|
||||
.parse(req.query),
|
||||
);
|
||||
});
|
||||
app.post('/api/admin/automatic-blocks/remove', { bodyLimit: 16384 }, async (req) =>
|
||||
service.admin.automatic.remove(
|
||||
service.admin.require(req.cookies.hop_account),
|
||||
automaticBlockRemoval.parse(req.body).ips,
|
||||
),
|
||||
);
|
||||
app.get('/api/ops/proxy-diagnostic', async (req) => {
|
||||
if (!cfg.proxyDiagnosticToken) fail(404, 'NOT_FOUND', '없는 경로입니다.');
|
||||
const given = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
|
||||
@@ -485,6 +569,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
input.email = normalizeEmail(input.email);
|
||||
if (input.email !== user.email)
|
||||
fail(410, 'EMAIL_TRANSFER_REMOVED', '이메일 전송 대신 내 임시 보관함을 이용해 주세요.');
|
||||
if (input.kind === 'file') service.admin.automatic.fileRequest(req, input.byteSize);
|
||||
const result = service.accounts.idem(user.id, 'request-create', key(req), input, () =>
|
||||
service.accounts.create(user.id, input),
|
||||
);
|
||||
@@ -494,6 +579,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
}
|
||||
const g = auth(req),
|
||||
input = requestInput.parse(req.body);
|
||||
if (input.kind === 'file') service.admin.automatic.fileRequest(req, input.byteSize);
|
||||
const result = service.idem(g, 'request-create', key(req), input, () =>
|
||||
service.createRequest(
|
||||
g,
|
||||
@@ -808,6 +894,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
});
|
||||
app.post('/api/auth/verify-email', async (req, reply) => {
|
||||
const result = service.auth.verify(tokenInput.parse(req.body).token, req.cookies.hop_account);
|
||||
service.admin.recordUser(req, result.account.id);
|
||||
setAccountCookie(reply, result);
|
||||
return { ok: true, account: result.account };
|
||||
});
|
||||
@@ -826,12 +913,21 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
);
|
||||
app.post('/api/auth/login', { bodyLimit: 16384 }, async (req, reply) => {
|
||||
const input = credentials.parse(req.body);
|
||||
const result = await service.auth.login(
|
||||
input.email,
|
||||
input.password,
|
||||
ip(req) ?? 'unknown',
|
||||
req.cookies.hop_account,
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
result = await service.auth.login(
|
||||
input.email,
|
||||
input.password,
|
||||
ip(req) ?? 'unknown',
|
||||
req.cookies.hop_account,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'LOGIN_FAILED')
|
||||
service.admin.automatic.observe(req, 'login');
|
||||
throw error;
|
||||
}
|
||||
service.admin.automatic.loginSuccess(req);
|
||||
service.admin.recordUser(req, result.account.id);
|
||||
setAccountCookie(reply, result);
|
||||
return { account: result.account };
|
||||
});
|
||||
@@ -937,6 +1033,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
|
||||
.strict(),
|
||||
])
|
||||
.parse(req.body);
|
||||
if (input.kind === 'file')
|
||||
service.admin.automatic.fileRequest(
|
||||
req,
|
||||
input.files.reduce((sum, file) => sum + file.size, 0),
|
||||
);
|
||||
const result = service.accounts.idem(user.id, 'vault-create', key(req), input, () => {
|
||||
const created = service.accounts.create(user.id, {
|
||||
email: user.email!,
|
||||
|
||||
@@ -231,10 +231,16 @@ export class Auth {
|
||||
const valid = await argon2
|
||||
.verify(user?.passwordHash ?? (await dummy), password)
|
||||
.catch(() => false);
|
||||
if (!valid || !user || !['pending', 'active'].includes(user.status))
|
||||
const currentUser = user ? this.user(user.id) : null;
|
||||
if (
|
||||
!valid ||
|
||||
!currentUser ||
|
||||
!['pending', 'active'].includes(currentUser.status) ||
|
||||
currentUser.passwordHash !== user?.passwordHash
|
||||
)
|
||||
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
|
||||
const previous = this.session(oldToken);
|
||||
const result = this.createSession(user!, oldToken);
|
||||
const result = this.createSession(currentUser!, oldToken);
|
||||
if (previous) this.endSession(previous);
|
||||
return result;
|
||||
}
|
||||
|
||||
134
apps/server/src/automaticBlocks.js
Normal file
134
apps/server/src/automaticBlocks.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import { eq, gt, lte, inArray } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { publicIp, ApiError, hash } from './security.js';
|
||||
import { visitorRegion } from './ownerInfo.js';
|
||||
|
||||
export class AutomaticBlocks {
|
||||
constructor(service) {
|
||||
this.service = service;
|
||||
this.counters = new Map();
|
||||
this.seenRequests = new Map();
|
||||
this.active = new Map(
|
||||
service.db
|
||||
.select()
|
||||
.from(s.automaticBlocks)
|
||||
.where(gt(s.automaticBlocks.expiresAt, Date.now()))
|
||||
.all()
|
||||
.map((row) => [row.ip, row]),
|
||||
);
|
||||
}
|
||||
has(ip) {
|
||||
return Boolean(ip && this.active.get(ip)?.expiresAt > Date.now());
|
||||
}
|
||||
resetCounters() {
|
||||
this.counters.clear();
|
||||
this.seenRequests.clear();
|
||||
}
|
||||
prune() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of this.counters) if (value.until <= now) this.counters.delete(key);
|
||||
for (const [key, until] of this.seenRequests) if (until <= now) this.seenRequests.delete(key);
|
||||
for (const [ip, row] of this.active) if (row.expiresAt <= now) this.active.delete(ip);
|
||||
this.service.db.delete(s.automaticBlocks).where(lte(s.automaticBlocks.expiresAt, now)).run();
|
||||
}
|
||||
observe(req, event, bytes = 0, requestKey) {
|
||||
const settings = this.service.admin.state;
|
||||
if (!settings.autoBlockEnabled) return false;
|
||||
const context = visitorRegion(req, this.service.cfg),
|
||||
ip = context.ip;
|
||||
// Never turn an unidentified proxy/NAS relay address into a ban for all visitors.
|
||||
if (!publicIp(ip)) return false;
|
||||
const token = req.cookies?.hop_account;
|
||||
if (token && this.service.admin.isAdmin(this.service.auth.session(token)?.user)) return false;
|
||||
if (this.has(ip)) return true;
|
||||
const now = Date.now(),
|
||||
until = now + settings.autoWindowMinutes * 60000;
|
||||
if (event === 'upload') {
|
||||
const key = `${ip}|${hash(requestKey)}`;
|
||||
if ((this.seenRequests.get(key) ?? 0) > now) return false;
|
||||
this.seenRequests.set(key, until);
|
||||
while (this.seenRequests.size > 10000)
|
||||
this.seenRequests.delete(this.seenRequests.keys().next().value);
|
||||
}
|
||||
const key = `${ip}|${event}`,
|
||||
previous = this.counters.get(key),
|
||||
counter = previous && previous.until > now ? previous : { count: 0, bytes: 0, until };
|
||||
counter.count++;
|
||||
counter.bytes += bytes;
|
||||
this.counters.set(key, counter);
|
||||
while (this.counters.size > 10000) this.counters.delete(this.counters.keys().next().value);
|
||||
const reason =
|
||||
event === 'login' && counter.count >= settings.autoLoginFailures
|
||||
? 'login-failures'
|
||||
: event === 'rate' && counter.count >= settings.autoRateViolations
|
||||
? 'rate-violations'
|
||||
: event === 'upload' && counter.count > settings.autoUploadRequests
|
||||
? 'file-requests'
|
||||
: event === 'upload' && counter.bytes > settings.autoUploadBytes
|
||||
? 'file-volume'
|
||||
: null;
|
||||
if (!reason || this.active.size >= 10000) return false;
|
||||
const row = {
|
||||
ip,
|
||||
countryCode: context.countryCode,
|
||||
reason,
|
||||
createdAt: now,
|
||||
expiresAt: now + settings.autoBlockMinutes * 60000,
|
||||
};
|
||||
this.service.db.transaction(() => {
|
||||
this.service.db
|
||||
.insert(s.automaticBlocks)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: s.automaticBlocks.ip,
|
||||
set: row,
|
||||
})
|
||||
.run();
|
||||
this.service.admin.audit(null, 'automatic-block', {
|
||||
...row,
|
||||
count: counter.count,
|
||||
bytes: counter.bytes,
|
||||
});
|
||||
});
|
||||
this.active.set(ip, row);
|
||||
this.service.admin.disconnectBlocked();
|
||||
return true;
|
||||
}
|
||||
fileRequest(req, bytes) {
|
||||
const key = `${req.method}:${req.url.split('?')[0]}:${req.cookies?.hop_guest ?? ''}:${req.headers['idempotency-key'] ?? req.id}`;
|
||||
if (this.observe(req, 'upload', bytes, key))
|
||||
throw new ApiError(
|
||||
403,
|
||||
'ACCESS_BLOCKED',
|
||||
'반복적인 파일 전송으로 이 IP의 이용이 잠시 제한됐어요.',
|
||||
);
|
||||
}
|
||||
loginSuccess(req) {
|
||||
const ip = visitorRegion(req, this.service.cfg).ip;
|
||||
if (ip) this.counters.delete(`${ip}|login`);
|
||||
}
|
||||
list({ offset = 0, q = '' } = {}) {
|
||||
const rows = [...this.active.values()]
|
||||
.filter((row) => row.expiresAt > Date.now() && row.ip.includes(q))
|
||||
.sort((a, b) => b.createdAt - a.createdAt || a.ip.localeCompare(b.ip));
|
||||
return {
|
||||
items: rows.slice(offset, offset + 50),
|
||||
total: rows.length,
|
||||
nextOffset: offset + 50 < rows.length ? offset + 50 : null,
|
||||
};
|
||||
}
|
||||
remove(actor, ips) {
|
||||
this.service.db.transaction(() => {
|
||||
this.service.db.delete(s.automaticBlocks).where(inArray(s.automaticBlocks.ip, ips)).run();
|
||||
this.service.admin.audit(actor, 'automatic-unblock', { ips });
|
||||
});
|
||||
for (const ip of ips) {
|
||||
this.active.delete(ip);
|
||||
for (const key of this.counters.keys())
|
||||
if (key.startsWith(`${ip}|`)) this.counters.delete(key);
|
||||
for (const key of this.seenRequests.keys())
|
||||
if (key.startsWith(`${ip}|`)) this.seenRequests.delete(key);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -368,6 +368,27 @@ export const adminSettings = sqliteTable('admin_settings', {
|
||||
value: text().notNull(),
|
||||
updatedAt: integer().notNull(),
|
||||
});
|
||||
export const automaticBlocks = sqliteTable(
|
||||
'automatic_blocks',
|
||||
{
|
||||
ip: text().primaryKey(),
|
||||
countryCode: text(),
|
||||
reason: text().notNull(),
|
||||
createdAt: integer().notNull(),
|
||||
expiresAt: integer().notNull(),
|
||||
},
|
||||
(t) => [index('automatic_block_expiry').on(t.expiresAt)],
|
||||
);
|
||||
export const userActivity = sqliteTable('user_activity', {
|
||||
userId: text()
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
lastSeenAt: integer().notNull(),
|
||||
ip: text(),
|
||||
countryCode: text(),
|
||||
region: text(),
|
||||
city: text(),
|
||||
});
|
||||
export const adminAudit = sqliteTable(
|
||||
'admin_audit',
|
||||
{
|
||||
|
||||
349
apps/server/test/admin-management.test.js
Normal file
349
apps/server/test/admin-management.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { fixture } from './helpers.js';
|
||||
import * as s from '../src/db/schema.js';
|
||||
import { auditChangeLines, formatCountry } from '../../web/src/displayFormat.js';
|
||||
|
||||
async function account(f, email) {
|
||||
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, userId: response.json().account.id, email };
|
||||
}
|
||||
const h = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR', 'cf-ipcity': 'Daejeon' };
|
||||
|
||||
test(
|
||||
'audit shows only differences, skips no-op saves, compares compatible old entries and supports individual/all deletion with authorization',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const admin = await account(f, 'hello@noname.company'),
|
||||
user = await account(f, 'user@hop.test');
|
||||
let response = await f.call(admin, '/admin/settings', 'PATCH', {
|
||||
revision: 0,
|
||||
maxConcurrentUploads: 5,
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
response = await f.call(admin, '/admin/settings', 'PATCH', {
|
||||
revision: 1,
|
||||
maxConcurrentUploads: 10,
|
||||
blockedCountries: ['CN'],
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
const events = (await f.call(admin, '/admin/audit')).json().events;
|
||||
const latest = events.find((e) => e.details.changes?.some((c) => c.after === 10));
|
||||
assert.deepEqual(latest.details.changes, [
|
||||
{ field: 'maxConcurrentUploads', before: 5, after: 10 },
|
||||
{ field: 'blockedCountries', added: ['CN'], removed: [] },
|
||||
]);
|
||||
assert.deepEqual(latest.details.changes.flatMap(auditChangeLines), [
|
||||
'동시에 처리할 업로드 수 5 > 10',
|
||||
'블랙리스트 추가: 중국(CN)',
|
||||
]);
|
||||
const count = events.length;
|
||||
await f.call(admin, '/admin/settings', 'PATCH', { revision: 2, maxConcurrentUploads: 10 });
|
||||
assert.equal((await f.call(admin, '/admin/audit')).json().events.length, count);
|
||||
assert.equal(
|
||||
(await f.call(user, '/admin/audit/remove', 'POST', { all: true })).statusCode,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(admin, '/admin/audit/remove', 'POST', { ids: [latest.id] })).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(admin, '/admin/audit')).json().events.some((e) => e.id === latest.id),
|
||||
false,
|
||||
);
|
||||
const before = {
|
||||
uploadsPaused: false,
|
||||
maxConcurrentUploads: 5,
|
||||
blockedCountries: [],
|
||||
blockedIps: [],
|
||||
totalStorageLimitMb: 20480,
|
||||
};
|
||||
for (const [offset, body] of [
|
||||
[2000, before],
|
||||
[1000, { ...before, maxConcurrentUploads: 6 }],
|
||||
])
|
||||
f.service.db
|
||||
.insert(s.adminAudit)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
actorUserId: admin.userId,
|
||||
action: 'settings-change',
|
||||
details: JSON.stringify(body),
|
||||
createdAt: Date.now() - offset,
|
||||
})
|
||||
.run();
|
||||
const old = (await f.call(admin, '/admin/audit'))
|
||||
.json()
|
||||
.events.find((e) => e.details.legacy && e.details.changes.length);
|
||||
assert.deepEqual(old.details.changes, [
|
||||
{ field: 'maxConcurrentUploads', before: 5, after: 6 },
|
||||
]);
|
||||
assert.equal(
|
||||
(await f.call(admin, '/admin/audit/remove', 'POST', { all: true })).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.deepEqual((await f.call(admin, '/admin/audit')).json().events, []);
|
||||
assert.equal(
|
||||
f.service.admin.state.maxConcurrentUploads,
|
||||
10,
|
||||
'deleting audit entries never changes settings',
|
||||
);
|
||||
assert.equal(formatCountry('JP'), '일본(JP)');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'user list keeps basic last-seen country/IP with throttled updates, expires location data and never exposes credentials',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const admin = await account(f, 'hello@noname.company'),
|
||||
user = await account(f, 'user@hop.test'),
|
||||
guest = await f.create();
|
||||
f.cfg.clientIpSource = 'cloudflare';
|
||||
await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
|
||||
const first = f.service.db
|
||||
.select()
|
||||
.from(s.userActivity)
|
||||
.where(eq(s.userActivity.userId, user.userId))
|
||||
.get();
|
||||
await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
|
||||
assert.equal(
|
||||
f.service.db
|
||||
.select()
|
||||
.from(s.userActivity)
|
||||
.where(eq(s.userActivity.userId, user.userId))
|
||||
.get().lastSeenAt,
|
||||
first.lastSeenAt,
|
||||
);
|
||||
const response = await f.call(
|
||||
admin,
|
||||
'/admin/users?q=user@hop.test',
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
h,
|
||||
),
|
||||
list = response.json().users;
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].email, user.email);
|
||||
assert.equal(list[0].countryCode, 'KR');
|
||||
assert.equal(list[0].city, 'Daejeon');
|
||||
assert.equal(list[0].ip, '8.8.4.4');
|
||||
assert.equal(response.body.includes('passwordHash'), false);
|
||||
assert.equal(response.body.includes('tokenHash'), false);
|
||||
assert.equal((await f.call(user, '/admin/users')).statusCode, 403);
|
||||
assert.equal((await f.call(guest, '/admin/users')).statusCode, 401);
|
||||
f.service.db
|
||||
.update(s.userActivity)
|
||||
.set({ lastSeenAt: Date.now() - 31 * 86400000 })
|
||||
.where(eq(s.userActivity.userId, user.userId))
|
||||
.run();
|
||||
const expired = (
|
||||
await f.call(admin, '/admin/users?q=user@hop.test', 'GET', undefined, undefined, h)
|
||||
).json().users[0];
|
||||
assert.equal(expired.ip, null);
|
||||
assert.equal(expired.countryCode, null);
|
||||
assert.ok(expired.lastSeenAt);
|
||||
f.service.admin.cleanup();
|
||||
assert.equal(
|
||||
f.service.db
|
||||
.select()
|
||||
.from(s.userActivity)
|
||||
.where(eq(s.userActivity.userId, user.userId))
|
||||
.get().city,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'admin reset sends a one-use email link; password and sessions change only when the user completes it',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const mail = [];
|
||||
const f = await fixture(false, async (message) => mail.push(message));
|
||||
try {
|
||||
const admin = await account(f, 'hello@noname.company'),
|
||||
user = await account(f, 'user@hop.test');
|
||||
assert.equal(
|
||||
(await f.call(user, `/admin/users/${admin.userId}/password-reset`, 'POST', {})).statusCode,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(admin, `/admin/users/${user.userId}/password-reset`, 'POST', {})).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(mail.length, 1);
|
||||
assert.equal(mail[0].to, user.email);
|
||||
assert.ok((await f.call(user, '/auth/me')).json().account);
|
||||
const token = /#reset-password=([^\s]+)/.exec(mail[0].text)[1];
|
||||
const reset = await f.call(user, '/auth/reset-password', 'POST', {
|
||||
token,
|
||||
password: 'new-password',
|
||||
passwordConfirmation: 'new-password',
|
||||
});
|
||||
assert.equal(reset.statusCode, 200, reset.body);
|
||||
assert.equal((await f.call(user, '/auth/me')).json().account, null);
|
||||
const guest = await f.create();
|
||||
assert.equal(
|
||||
(await f.call(guest, '/auth/login', 'POST', { email: user.email, password: '12345678' }))
|
||||
.statusCode,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(guest, '/auth/login', 'POST', {
|
||||
email: user.email,
|
||||
password: 'new-password',
|
||||
})
|
||||
).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(guest, '/auth/reset-password', 'POST', {
|
||||
token,
|
||||
password: 'again-password',
|
||||
passwordConfirmation: 'again-password',
|
||||
})
|
||||
).statusCode,
|
||||
400,
|
||||
);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'admin deletion confirms email, protects the administrator, revokes credentials and preserves shared recipient files',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const admin = await account(f, 'hello@noname.company'),
|
||||
user = await account(f, 'user@hop.test'),
|
||||
recipient = await f.create();
|
||||
const created = await f.call(
|
||||
user,
|
||||
'/vault',
|
||||
'POST',
|
||||
{ kind: 'file', files: [{ name: 'shared.txt', size: 4, type: 'text/plain' }] },
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(created.statusCode, 200, created.body);
|
||||
const id = created.json().requestId,
|
||||
payload = Buffer.from(
|
||||
'--delete\r\nContent-Disposition: form-data; name="file"; filename="shared.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n',
|
||||
);
|
||||
const uploaded = await f.call(
|
||||
user,
|
||||
`/uploads?requestId=${id}&fileIndex=0`,
|
||||
'POST',
|
||||
payload,
|
||||
randomUUID(),
|
||||
{ 'content-type': 'multipart/form-data; boundary=delete' },
|
||||
);
|
||||
assert.equal(uploaded.statusCode, 200, uploaded.body);
|
||||
const file = f.service.storage.fileRows(id)[0],
|
||||
item = f.service.db.select().from(s.items).where(eq(s.items.fileId, file.id)).get(),
|
||||
accessId = randomUUID();
|
||||
f.service.db
|
||||
.insert(s.access)
|
||||
.values({
|
||||
id: accessId,
|
||||
itemId: item.id,
|
||||
guestId: f.service.deviceGuest(recipient.deviceId).guestId,
|
||||
role: 'recipient',
|
||||
read: false,
|
||||
})
|
||||
.run();
|
||||
assert.equal(
|
||||
(await f.call(admin, `/admin/users/${admin.userId}/delete`, 'POST', { email: admin.email }))
|
||||
.statusCode,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', {
|
||||
email: 'wrong@hop.test',
|
||||
})
|
||||
).statusCode,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(user, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
|
||||
.statusCode,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
|
||||
.statusCode,
|
||||
200,
|
||||
);
|
||||
const deleted = f.service.auth.user(user.userId);
|
||||
assert.equal(deleted.status, 'deleted');
|
||||
assert.equal(deleted.email, null);
|
||||
assert.equal(deleted.passwordHash, null);
|
||||
assert.equal((await f.call(user, '/auth/me')).json().account, null);
|
||||
assert.equal(
|
||||
(await f.call(admin, '/admin/users')).json().users.some((u) => u.id === user.userId),
|
||||
false,
|
||||
);
|
||||
const download = await f.call(recipient, `/access/${accessId}/download`);
|
||||
assert.equal(download.statusCode, 200, download.body);
|
||||
assert.equal(download.body, 'test');
|
||||
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'account-delete');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'account deletion during password verification cannot issue a usable new login session',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const user = await account(f, 'race@hop.test');
|
||||
const login = f.service.auth.login(user.email, '12345678', '8.8.8.8');
|
||||
f.service.db
|
||||
.update(s.users)
|
||||
.set({ status: 'deleted', email: null, passwordHash: null })
|
||||
.where(eq(s.users.id, user.userId))
|
||||
.run();
|
||||
await assert.rejects(login, (error) => error.code === 'LOGIN_FAILED');
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
487
apps/server/test/network.test.js
Normal file
487
apps/server/test/network.test.js
Normal file
@@ -0,0 +1,487 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user