mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
fix(api): validate push and domain verification targets (#2346)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {Resolver} from 'node:dns/promises';
|
import {Resolver} from 'node:dns/promises';
|
||||||
|
import {isFqdnHostname} from '@fluxer/schema/src/primitives/UrlValidators';
|
||||||
import {Logger} from '../../Logger';
|
import {Logger} from '../../Logger';
|
||||||
import {EXTERNAL_RESPONSE_LIMITS} from '../../utils/ExternalResponseLimits';
|
import {EXTERNAL_RESPONSE_LIMITS} from '../../utils/ExternalResponseLimits';
|
||||||
import * as FetchUtils from '../../utils/FetchUtils';
|
import * as FetchUtils from '../../utils/FetchUtils';
|
||||||
@@ -51,6 +52,9 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkDnsTxt(domain: string, token: string): Promise<boolean> {
|
private async checkDnsTxt(domain: string, token: string): Promise<boolean> {
|
||||||
|
if (!isFqdnHostname(domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const recordDomain = `_fluxer.${domain}`;
|
const recordDomain = `_fluxer.${domain}`;
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
this.dnsServers.map(async (dnsServer) => {
|
this.dnsServers.map(async (dnsServer) => {
|
||||||
@@ -81,9 +85,27 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async checkWellKnown(domain: string, token: string): Promise<boolean> {
|
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 {
|
try {
|
||||||
const response = await FetchUtils.sendRequest({
|
const response = await FetchUtils.sendRequest({
|
||||||
url: `https://${domain}/.well-known/fluxer-verification`,
|
url: url.href,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
timeout: VERIFICATION_TIMEOUT_MS,
|
timeout: VERIFICATION_TIMEOUT_MS,
|
||||||
serviceName: 'connection_verification',
|
serviceName: 'connection_verification',
|
||||||
@@ -94,7 +116,7 @@ export class DomainConnectionVerifier implements IConnectionVerifier {
|
|||||||
const body = await FetchUtils.streamToStringWithLimit(response.stream, {
|
const body = await FetchUtils.streamToStringWithLimit(response.stream, {
|
||||||
maxBytes: EXTERNAL_RESPONSE_LIMITS.domainVerificationBytes,
|
maxBytes: EXTERNAL_RESPONSE_LIMITS.domainVerificationBytes,
|
||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
url: `https://${domain}/.well-known/fluxer-verification`,
|
url: url.href,
|
||||||
description: 'Domain verification response',
|
description: 'Domain verification response',
|
||||||
});
|
});
|
||||||
return body.trim() === token;
|
return body.trim() === token;
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import type {Readable} from 'node:stream';
|
import type {Readable} from 'node:stream';
|
||||||
import {MAX_BOOKMARKS_NON_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
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 {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
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 {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
|
||||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||||
@@ -22,6 +24,7 @@ import type {
|
|||||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||||
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
import type {SavedMessageStatus} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
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 type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||||
import {ms} from 'itty-time';
|
import {ms} from 'itty-time';
|
||||||
import type {ApiContext} from '../../ApiContext';
|
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);
|
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 {
|
function normalizeMobileAppId(appId: string | undefined): string {
|
||||||
const normalized = appId?.trim();
|
const normalized = appId?.trim();
|
||||||
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
|
return normalized && normalized.length > 0 ? normalized : DEFAULT_MOBILE_APP_ID;
|
||||||
@@ -295,6 +310,7 @@ export class UserContentService {
|
|||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}): Promise<PushSubscription> {
|
}): Promise<PushSubscription> {
|
||||||
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
|
const {userId, authSessionIdHash, endpoint, keys, userAgent} = params;
|
||||||
|
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||||
const subscriptionId = createWebPushSubscriptionId(endpoint);
|
const subscriptionId = createWebPushSubscriptionId(endpoint);
|
||||||
const data: PushSubscriptionRow = {
|
const data: PushSubscriptionRow = {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -335,6 +351,7 @@ export class UserContentService {
|
|||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
}): Promise<PushSubscription> {
|
}): Promise<PushSubscription> {
|
||||||
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
|
const {userId, authSessionIdHash, oldEndpoint, endpoint, keys, userAgent} = params;
|
||||||
|
assertPublicPushEndpoint(endpoint, 'endpoint');
|
||||||
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
|
const oldSubscriptionId = createWebPushSubscriptionId(oldEndpoint);
|
||||||
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
|
const newSubscriptionId = createWebPushSubscriptionId(endpoint);
|
||||||
if (oldSubscriptionId !== newSubscriptionId) {
|
if (oldSubscriptionId !== newSubscriptionId) {
|
||||||
@@ -359,6 +376,9 @@ export class UserContentService {
|
|||||||
|
|
||||||
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
|
async registerMobileDevice(params: RegisterMobileDeviceParams): Promise<PushSubscription> {
|
||||||
const {userId, authSessionIdHash, device} = params;
|
const {userId, authSessionIdHash, device} = params;
|
||||||
|
if (device.platform === 'android_unified_push') {
|
||||||
|
assertPublicPushEndpoint(device.token, 'token');
|
||||||
|
}
|
||||||
const appId = normalizeMobileAppId(device.app_id);
|
const appId = normalizeMobileAppId(device.app_id);
|
||||||
const providerEnvironment = normalizeProviderEnvironment(device.platform, device.provider_environment);
|
const providerEnvironment = normalizeProviderEnvironment(device.platform, device.provider_environment);
|
||||||
const subscriptionId = createPushSubscriptionId([device.platform, appId, providerEnvironment ?? '', device.token]);
|
const subscriptionId = createPushSubscriptionId([device.platform, appId, providerEnvironment ?? '', device.token]);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Int32Type,
|
Int32Type,
|
||||||
withOpenApiType,
|
withOpenApiType,
|
||||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||||
|
import {HostnameType} from '@fluxer/schema/src/primitives/UrlValidators';
|
||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
|
|
||||||
const ConnectionTypeSchema = withOpenApiType(
|
const ConnectionTypeSchema = withOpenApiType(
|
||||||
@@ -62,7 +63,7 @@ export type VerifyAndCreateConnectionRequest = z.infer<typeof VerifyAndCreateCon
|
|||||||
|
|
||||||
export const CreateConnectionRequest = z.object({
|
export const CreateConnectionRequest = z.object({
|
||||||
type: ConnectionTypeSchema.describe('The type of connection to create'),
|
type: ConnectionTypeSchema.describe('The type of connection to create'),
|
||||||
identifier: z.string().min(1).max(253).describe('The connection identifier (handle or domain)'),
|
identifier: HostnameType.describe('The connection identifier (handle or domain)'),
|
||||||
visibility_flags: Int32Type.optional().describe('Bitfield controlling who can see this connection'),
|
visibility_flags: Int32Type.optional().describe('Bitfield controlling who can see this connection'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -514,6 +514,13 @@ export const RegisterMobileDeviceRequest = z
|
|||||||
})
|
})
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
if (value.platform !== 'android_unified_push') return;
|
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) {
|
if (!value.encryption_key) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {z} from 'zod';
|
|||||||
|
|
||||||
const PROTOCOLS = ['http', 'https'];
|
const PROTOCOLS = ['http', 'https'];
|
||||||
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
|
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 = {
|
const URL_VALIDATOR_OPTIONS = {
|
||||||
require_protocol: true,
|
require_protocol: true,
|
||||||
require_host: 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 URLType = createUrlSchema();
|
||||||
export const AttachmentURLType = z
|
export const AttachmentURLType = z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
Reference in New Issue
Block a user