fix(api): validate push and domain verification targets (#2346)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:23:34 +02:00
1 parent 693aec2b4d
commit 1d84689b45
5 files changed
+75 -3

No files matched your search

@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Resolver} from 'node:dns/promises';
import {isFqdnHostname} from '@fluxer/schema/src/primitives/UrlValidators';
import {Logger} from '../../Logger';
import {EXTERNAL_RESPONSE_LIMITS} from '../../utils/ExternalResponseLimits';
import * as FetchUtils from '../../utils/FetchUtils';
@@ -51,6 +52,9 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
}
private async checkDnsTxt(domain: string, token: string): Promise<boolean> {
if (!isFqdnHostname(domain)) {
return false;
}
const recordDomain = `_fluxer.${domain}`;
const results = await Promise.allSettled(
this.dnsServers.map(async (dnsServer) => {
@@ -81,9 +85,27 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
}
private async checkWellKnown(domain: string, token: string): Promise<boolean> {
const verificationPath = '/.well-known/fluxer-verification';
let url: URL;
try {
url = new URL(verificationPath, `https://${domain}`);
} catch {
return false;
}
if (
url.host !== domain ||
url.username !== '' ||
url.password !== '' ||
url.port !== '' ||
url.pathname !== verificationPath ||
url.search !== '' ||
url.hash !== ''
) {
return false;
}
try {
const response = await FetchUtils.sendRequest({
url: `https://${domain}/.well-known/fluxer-verification`,
url: url.href,
method: 'GET',
timeout: VERIFICATION_TIMEOUT_MS,
serviceName: 'connection_verification',
@@ -94,7 +116,7 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
const body = await FetchUtils.streamToStringWithLimit(response.stream, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.domainVerificationBytes,
headers: response.headers,
url: `https://${domain}/.well-known/fluxer-verification`,
url: url.href,
description: 'Domain verification response',
});
return body.trim() === token;
@@ -3,8 +3,10 @@
import crypto from 'node:crypto';
import type {Readable} from 'node:stream';
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
@@ -22,6 +24,7 @@ import type {
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {isPubliclyRoutableUrlShape} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
import {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
@@ -85,6 +88,18 @@ function createWebPushSubscriptionId(endpoint: string): string {
return crypto.createHash('sha256').update(endpoint).digest('hex').substring(0, 32);
}
function assertPublicPushEndpoint(endpoint: string, fieldName: string): void {
let parsedUrl: URL;
try {
parsedUrl = new URL(endpoint);
} catch {
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
}
if (!isPubliclyRoutableUrlShape(parsedUrl)) {
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.URL_NOT_PUBLICLY_ROUTABLE);
}
}
function normalizeMobileAppId(appId: string | undefined): string {
const normalized = appId?.trim();
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
@@ -295,6 +310,7 @@ export class UserContentService {
userAgent?: string;
}): Promise<PushSubscription> {
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
assertPublicPushEndpoint(endpoint, 'endpoint');
const subscriptionId = createWebPushSubscriptionId(endpoint);
const data: PushSubscriptionRow = {
user_id: userId,
@@ -335,6 +351,7 @@ export class UserContentService {
userAgent?: string;
}): Promise<PushSubscription> {
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
assertPublicPushEndpoint(endpoint, 'endpoint');
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
if (oldSubscriptionId !== newSubscriptionId) {
@@ -359,6 +376,9 @@ export class UserContentService {
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
const {userId, authSessionIdHash, device} = params;
if (device.platform === 'android_unified_push') {
assertPublicPushEndpoint(device.token, 'token');
}
const appId = normalizeMobileAppId(device.app_id);
const providerEnvironment = normalizeProviderEnvironment(device.platform, device.provider_environment);
const subscriptionId = createPushSubscriptionId([device.platform, appId, providerEnvironment ?? '', device.token]);