diff --git a/fluxer_api/src/api/connection/verification/DomainConnectionVerifier.ts b/fluxer_api/src/api/connection/verification/DomainConnectionVerifier.ts index 4163c1354..1ad154c06 100644 --- a/fluxer_api/src/api/connection/verification/DomainConnectionVerifier.ts +++ b/fluxer_api/src/api/connection/verification/DomainConnectionVerifier.ts @@ -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 { + 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 { + 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; diff --git a/fluxer_api/src/api/user/services/UserContentService.ts b/fluxer_api/src/api/user/services/UserContentService.ts index b41e2a039..fbc480889 100644 --- a/fluxer_api/src/api/user/services/UserContentService.ts +++ b/fluxer_api/src/api/user/services/UserContentService.ts @@ -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 { 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 { 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 { 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]); diff --git a/packages/schema/src/domains/connection/ConnectionSchemas.ts b/packages/schema/src/domains/connection/ConnectionSchemas.ts index 3c450e7a2..c8438e351 100644 --- a/packages/schema/src/domains/connection/ConnectionSchemas.ts +++ b/packages/schema/src/domains/connection/ConnectionSchemas.ts @@ -11,6 +11,7 @@ import { Int32Type, withOpenApiType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import {HostnameType} from '@fluxer/schema/src/primitives/UrlValidators'; import {z} from 'zod'; const ConnectionTypeSchema = withOpenApiType( @@ -62,7 +63,7 @@ export type VerifyAndCreateConnectionRequest = z.infer { if (value.platform !== 'android_unified_push') return; + if (!URLType.safeParse(value.token).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['token'], + message: 'UnifiedPush registrations require a valid endpoint URL', + }); + } if (!value.encryption_key) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/packages/schema/src/primitives/UrlValidators.ts b/packages/schema/src/primitives/UrlValidators.ts index dee7b0dcc..81f54925d 100644 --- a/packages/schema/src/primitives/UrlValidators.ts +++ b/packages/schema/src/primitives/UrlValidators.ts @@ -7,6 +7,7 @@ import {z} from 'zod'; const PROTOCOLS = ['http', 'https']; const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u; +const HOSTNAME_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const URL_VALIDATOR_OPTIONS = { require_protocol: true, require_host: true, @@ -50,6 +51,27 @@ function createUrlSchema() { ); } +export function isFqdnHostname(hostname: string): boolean { + if (!hostname || hostname.length > 253 || !hostname.includes('.')) { + return false; + } + const labels = hostname.split('.'); + for (const label of labels) { + if (!label || label.length > 63 || !HOSTNAME_LABEL_REGEX.test(label)) { + return false; + } + } + return !/^\d+$/.test(labels[labels.length - 1]); +} + +export const HostnameType = z + .string() + .transform((value) => { + const trimmed = value.trim().toLowerCase(); + return trimmed.endsWith('.') ? trimmed.slice(0, -1) : trimmed; + }) + .refine(isFqdnHostname, ValidationErrorCodes.INVALID_FORMAT); + export const URLType = createUrlSchema(); export const AttachmentURLType = z .string()