chore: tidy request handling across services (#3168)

This commit is contained in:
Hampus
2026-10-03 15:36:33 +02:00
committed by GitHub
parent c6941d5905
commit da9e9ff0be
11 changed files with 428 additions and 44 deletions
@@ -56,8 +56,8 @@ async function verifySudoMode(
const apiContext = ctx.get('apiContext');
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
const hasPasskeyCredentials = credentials.length > 0;
const hasMfa = userHasMfa(user);
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
const hasUsableMfa = userHasMfa(user) && hasSudoCapability;
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
if (hasSudoCapability && ctx.get('sudoModeValid')) {
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
@@ -82,10 +82,10 @@ async function verifySudoMode(
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
if (hasNoVerifiableCredential(user, hasUsableMfa, hasPasskeyCredentials)) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
if (body.password && !hasUsableMfa) {
if (!user.passwordHash) {
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
}
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
generateTotpCode,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {setWebAuthnTwoFactor} from '@app/api/auth/tests/WebAuthnTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PrivateUserResponse {
mfa_enabled: boolean;
authenticator_types: Array<number>;
}
interface SudoModeRequiredResponse {
code: string;
has_mfa?: boolean;
}
async function setAuthenticatorTypes(account: TestAccount, types: Array<number>): Promise<void> {
const users = getUserRepository();
const user = (await users.findUnique(createUserID(BigInt(account.userId))))!;
await users.patchUpsert(user.id, {authenticator_types: new Set<number>(types)}, user.toRow());
}
describe('Sudo mode for accounts whose authenticator types list no usable factor', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('accepts the password and lets the account turn passkey two-factor off when no passkey remains', async () => {
const account = await createTestAccount(harness);
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.WEBAUTHN]);
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
expect(challenge.has_mfa).toBe(false);
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
expect(disabled.user.authenticator_types).toEqual([]);
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
expect(me.mfa_enabled).toBe(false);
});
it('accepts the password when the TOTP type is listed without a stored secret', async () => {
const account = await createTestAccount(harness);
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.TOTP]);
await createBuilder(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, password: 'wrong-password'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
});
it('still refuses the password from an account with TOTP enrolled', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: generateTotpCode(secret), password: account.password})
.execute();
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
.put('/users/@me/mfa/webauthn/two-factor')
.body({enabled: false, password: account.password})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
expect(challenge.has_mfa).toBe(true);
});
});
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isTestSsoProvider, normalizeAndValidateSsoConfig} from '@app/api/instance/SsoConfigValidation';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {describe, expect, it} from 'vitest';
function ssoConfig(overrides: {authorizationUrl?: string | null; tokenUrl?: string | null} = {}) {
return {
enabled: true,
enforced: false,
issuer: null,
authorizationUrl: 'test',
tokenUrl: 'test',
userInfoUrl: null,
jwksUrl: null,
clientId: 'client',
allowedEmailDomains: [],
...overrides,
};
}
describe('isTestSsoProvider', () => {
it('recognises the placeholder provider only when test mode is enabled', () => {
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, true)).toBe(true);
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, true)).toBe(true);
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, true)).toBe(true);
});
it('never recognises the placeholder provider outside test mode', () => {
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, false)).toBe(false);
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, false)).toBe(false);
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: 'test'}, false)).toBe(false);
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, false)).toBe(false);
});
});
describe('normalizeAndValidateSsoConfig placeholder endpoints', () => {
it('accepts placeholder endpoints in test mode', async () => {
const result = await normalizeAndValidateSsoConfig(ssoConfig(), {testModeEnabled: true});
expect(result.ready).toBe(true);
expect(result.authorizationUrl).toBe('test');
});
it('rejects a placeholder authorization endpoint outside test mode', async () => {
await expect(
normalizeAndValidateSsoConfig(ssoConfig({tokenUrl: null}), {testModeEnabled: false}),
).rejects.toBeInstanceOf(InputValidationError);
});
it('rejects a placeholder token endpoint outside test mode', async () => {
await expect(
normalizeAndValidateSsoConfig(ssoConfig({authorizationUrl: null}), {testModeEnabled: false}),
).rejects.toBeInstanceOf(InputValidationError);
});
});
@@ -64,10 +64,13 @@ export function isTestSsoProvider(
},
testModeEnabled: boolean,
): boolean {
if (!testModeEnabled) {
return false;
}
return (
config.authorizationUrl === 'test' ||
config.tokenUrl === 'test' ||
(testModeEnabled && (config.authorizationUrl?.startsWith('test-') ?? false))
(config.authorizationUrl?.startsWith('test-') ?? false)
);
}
@@ -95,6 +95,8 @@ const DSA_CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const DSA_CODE_SEGMENT_LENGTH = 4;
const DSA_CODE_SEPARATOR = '-';
const DSA_TICKET_BYTES = 32;
const DSA_EMAIL_SEND_RECIPIENT_MAX = 3;
const DSA_EMAIL_SEND_RECIPIENT_WINDOW = ms('1 hour');
async function emitReportFiled(row: IARSubmissionRow, target: ReportTarget): Promise<void> {
const key = row.reported_user_id ?? row.reporter_id;
@@ -373,6 +375,20 @@ export class ReportService {
async sendDsaReportVerificationCode(email: string, locale: string | null = null): Promise<void> {
const normalizedEmail = this.normalizeEmail(email);
const recipientLimit = await this.rateLimitService.checkLimit({
identifier: `dsa:report:email:send:recipient:${normalizedEmail}`,
maxAttempts: DSA_EMAIL_SEND_RECIPIENT_MAX,
windowMs: DSA_EMAIL_SEND_RECIPIENT_WINDOW,
});
if (!recipientLimit.allowed) {
throw new RateLimitError({
retryAfter: recipientLimit.retryAfter,
retryAfterDecimal: recipientLimit.retryAfterDecimal,
limit: recipientLimit.limit,
resetTime: recipientLimit.resetTime,
resetAfterDecimal: recipientLimit.resetAfterDecimal,
});
}
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
if (!hasValidDns) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {clearTestEmails, createUniqueEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
async function countVerificationEmailsTo(harness: ApiTestHarness, email: string): Promise<number> {
const emails = await listTestEmails(harness);
return emails.filter((sent) => sent.type === 'dsa_report_verification' && sent.to === email.toLowerCase()).length;
}
describe('DSA report verification email recipient limit', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('limits verification emails per address regardless of letter case', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-recipient');
for (let attempt = 0; attempt < 3; attempt++) {
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email})
.expect(HTTP_STATUS.OK)
.execute();
}
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email: email.toUpperCase()})
.expect(429)
.execute();
expect(await countVerificationEmailsTo(harness, email)).toBe(3);
});
test('keeps sending to other addresses after one address reaches its limit', async () => {
await clearTestEmails(harness);
const limited = createUniqueEmail('dsa-recipient');
for (let attempt = 0; attempt < 3; attempt++) {
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email: limited}).execute();
}
const other = createUniqueEmail('dsa-recipient');
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email: other})
.expect(HTTP_STATUS.OK)
.execute();
expect(await countVerificationEmailsTo(harness, other)).toBe(1);
});
});
@@ -467,6 +467,7 @@ export function WebhookController(app: HonoApp) {
app.post(
'/webhooks/:webhook_id/:token/github',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GITHUB),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'execute_github_webhook',
summary: 'Execute GitHub webhook',
@@ -494,6 +495,7 @@ export function WebhookController(app: HonoApp) {
app.post(
'/webhooks/:webhook_id/:token/slack',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_EXECUTE),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'execute_slack_webhook',
summary: 'Execute Slack webhook',
@@ -520,6 +522,7 @@ export function WebhookController(app: HonoApp) {
app.post(
'/webhooks/:webhook_id/:token/instatus',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_INSTATUS),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'execute_instatus_webhook',
summary: 'Execute Instatus webhook',
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, it} from 'vitest';
const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus'];
describe('Webhook token routes and the web app origin', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
for (const suffix of TOKEN_ROUTE_SUFFIXES) {
it(`refuses POST /webhooks/:webhook_id/:token${suffix} from the web app origin`, async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'App Origin Guild');
const webhook = await createWebhook(harness, guild.system_channel_id!, owner.token, 'App Origin Webhook');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}${suffix}`)
.header('origin', getConfig().endpoints.webAppOrigins[0]!)
.body({content: 'hello'})
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
.execute();
});
}
});