Add network protection and administrator account support

This commit is contained in:
2026-10-15 12:05:52 +09:00
parent 471e2653d8
commit 4d527f74e6
20 changed files with 2913 additions and 106 deletions

View 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
);

View File

@@ -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
}
]
}

View File

@@ -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))

View File

@@ -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!,

View File

@@ -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;
}

View 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 };
}
}

View File

@@ -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',
{

View 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();
}
},
);

View 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();
}
},
);