mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 19:00:14 +00:00
* fix(MM-70606): send the pre-auth secret over HTTPS only and fix Edit Server save - sanitizeUrl: useHttp no longer downgrades an explicit https:// URL, so getServerUrlAfterRedirect keeps an HTTPS redirect target and strips its query string. - Send the pre-auth secret header only to https/wss URLs: REST client default headers, per-request headers, WebSocket, and the HEAD probe. The secret is still persisted for the server. - Edit Server: try HTTPS first, then fall back to HTTP (as Add Server does). - Edit Server: recreate and reconnect the WebSocket client after the secret changes, so the new secret is used without an app restart. * fix(MM-70606): keep the persisted pre-auth secret on token refresh over HTTP ClientTracking stores the secret in its own field. The token refresh in doFetchWithTracking reads that field instead of the request header, which is only set over TLS, so a refresh on an http:// server no longer resets the saved secret. * fix(MM-70606): clarify when the pre-auth secret header is removed * test(MM-70606): cover the pre-auth header guard in NetworkManager.createClient
487 lines
19 KiB
TypeScript
487 lines
19 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {defineMessage} from 'react-intl';
|
|
import {DeviceEventEmitter, Platform} from 'react-native';
|
|
|
|
import {CollectNetworkMetrics} from '@assets/config.json';
|
|
import {Events} from '@constants';
|
|
import {setPreauthSecret, setServerCredentials} from '@init/credentials';
|
|
import NetworkPerformanceManager from '@managers/network_performance_manager';
|
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
|
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
|
|
import {getFullErrorMessage, isErrorWithStatusCode, preauthSecretInvalidMessage} from '@utils/errors';
|
|
import {getFormattedFileSize} from '@utils/file';
|
|
import {isPreauthRejection} from '@utils/headers';
|
|
import {logDebug, logInfo, logWarning} from '@utils/log';
|
|
import {semverFromServerVersion} from '@utils/server';
|
|
import {cleanUrlForLogging, isSecureUrl} from '@utils/url';
|
|
|
|
import * as ClientConstants from './constants';
|
|
import ClientError from './error';
|
|
|
|
import type {APIClientInterface, ClientHeaders, ClientResponse, ClientResponseMetrics, RequestOptions} from '@mattermost/react-native-network-client';
|
|
|
|
type UrlData = {
|
|
count: number;
|
|
metrics?: ClientResponseMetrics;
|
|
}
|
|
|
|
type GroupData = {
|
|
activeCount: number;
|
|
startTime: number;
|
|
totalSize: number;
|
|
totalCompressedSize: number;
|
|
urls: Record<string, UrlData>;
|
|
completionFlag: boolean;
|
|
completionTimer?: NodeJS.Timeout;
|
|
}
|
|
|
|
/**
|
|
* ParallelGroup
|
|
* @typedef {Object} ParallelGroup
|
|
* @property {number} startTime is the start time of all requests in the group
|
|
* @property {number} endTime is min end time of all requests in the group
|
|
* @property {number} latency is the max latency (in ms)
|
|
* @property {ClientResponseMetrics[]} requests is the list of requests in the group
|
|
*/
|
|
type ParallelGroup = {
|
|
startTime: number;
|
|
endTime: number;
|
|
latency: number;
|
|
requests: ClientResponseMetrics[];
|
|
}
|
|
|
|
type CategorizedRequestsResult = {
|
|
parallelGroups: ParallelGroup[];
|
|
maxConcurrency: number;
|
|
};
|
|
|
|
export const testExports = {
|
|
ParallelGroup: {} as ParallelGroup,
|
|
};
|
|
|
|
export default class ClientTracking {
|
|
apiClient: APIClientInterface;
|
|
csrfToken = '';
|
|
requestHeaders: {[x: string]: string} = {};
|
|
serverVersion = '';
|
|
urlVersion = '/api/v4';
|
|
enableLogging = false;
|
|
|
|
requestGroups: Map<string, GroupData> = new Map();
|
|
|
|
constructor(apiClient: APIClientInterface) {
|
|
this.apiClient = apiClient;
|
|
}
|
|
|
|
setClientCredentials(bearerToken: string, preauthSecret?: string) {
|
|
if (bearerToken) {
|
|
this.requestHeaders[ClientConstants.HEADER_AUTH] = `${ClientConstants.HEADER_BEARER} ${bearerToken}`;
|
|
}
|
|
|
|
// Undefined means leave the in-memory header alone (token-only refresh must not drop it).
|
|
// Clear the header with setPreauthSecretHeader(''); clear storage with removePreauthSecret.
|
|
// The secret is still persisted below, but it is only sent over TLS.
|
|
if (preauthSecret && isSecureUrl(this.apiClient.baseUrl)) {
|
|
this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = preauthSecret;
|
|
}
|
|
|
|
// Nothing is persisted before there is a session: an unauthenticated ping must not store a
|
|
// secret the server has not accepted yet. Sequenced so the credential the secret attaches to
|
|
// already exists in the cache.
|
|
if (bearerToken) {
|
|
setServerCredentials(this.apiClient.baseUrl, bearerToken).then(() => {
|
|
if (preauthSecret) {
|
|
return setPreauthSecret(this.apiClient.baseUrl, preauthSecret);
|
|
}
|
|
|
|
return undefined;
|
|
}).catch((e) => {
|
|
logWarning('ClientTracking.setClientCredentials: persist failed', getFullErrorMessage(e));
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the pre-auth secret on the live native session. Passing an empty string clears it.
|
|
* The secret is only sent over TLS, so it is cleared on an insecure base URL.
|
|
*
|
|
* Deliberately not implemented by invalidating and recreating the client: tearing down a
|
|
* URLSession that still has requests in flight aborts the process from CFNetwork with
|
|
* "Task created in a session that has been invalidated".
|
|
*/
|
|
setPreauthSecretHeader(preauthSecret: string) {
|
|
const headerValue = isSecureUrl(this.apiClient.baseUrl) ? preauthSecret : '';
|
|
if (headerValue) {
|
|
this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET] = headerValue;
|
|
} else {
|
|
delete this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
|
|
}
|
|
|
|
return this.apiClient.addHeaders({
|
|
[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: headerValue,
|
|
});
|
|
}
|
|
|
|
setCSRFToken(csrfToken: string) {
|
|
this.csrfToken = csrfToken;
|
|
}
|
|
|
|
getRequestHeaders(requestMethod: string) {
|
|
const headers = {...this.requestHeaders};
|
|
|
|
headers[ClientConstants.HEADER_ACCEPT]= 'application/json';
|
|
|
|
if (this.csrfToken && requestMethod.toLowerCase() !== 'get') {
|
|
headers[ClientConstants.HEADER_X_CSRF_TOKEN] = this.csrfToken;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
initTrackGroup(groupLabel: RequestGroupLabel) {
|
|
if (!this.requestGroups.has(groupLabel)) {
|
|
this.requestGroups.set(groupLabel, {
|
|
activeCount: 0,
|
|
startTime: Date.now(),
|
|
totalSize: 0,
|
|
totalCompressedSize: 0,
|
|
urls: {},
|
|
completionFlag: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
trackRequest(groupLabel: RequestGroupLabel, url: string, metrics?: ClientResponseMetrics) {
|
|
this.initTrackGroup(groupLabel);
|
|
|
|
const group = this.requestGroups.get(groupLabel)!;
|
|
|
|
if (group.urls[url]) {
|
|
group.urls[url].count += 1;
|
|
} else {
|
|
group.urls[url] = {
|
|
count: 1,
|
|
metrics,
|
|
};
|
|
}
|
|
group.totalSize += metrics?.size ?? 0;
|
|
group.totalCompressedSize += metrics?.compressedSize ?? 0;
|
|
}
|
|
|
|
getAverageLatency(groupLabel: RequestGroupLabel): number {
|
|
const groupData = this.requestGroups.get(groupLabel);
|
|
if (!groupData) {
|
|
return 100;
|
|
}
|
|
|
|
const urlData = Object.entries(groupData.urls);
|
|
const sumLatency = urlData.reduce((result, url) => (result + (url[1].metrics?.latency ?? 0)), 0);
|
|
return sumLatency / urlData.length;
|
|
}
|
|
|
|
categorizeRequests(groupLabel: RequestGroupLabel): CategorizedRequestsResult {
|
|
const groupData = this.requestGroups.get(groupLabel);
|
|
if (!groupData) {
|
|
const emptyResult: CategorizedRequestsResult = {
|
|
parallelGroups: [],
|
|
maxConcurrency: 0,
|
|
};
|
|
return emptyResult;
|
|
}
|
|
|
|
const requestsMetrics = Object.entries(groupData.urls).
|
|
map((e) => e[1].metrics).
|
|
filter((m) => m != null);
|
|
requestsMetrics.sort((a, b) => a.startTime - b.startTime);
|
|
|
|
const parallelGroups: ParallelGroup[] = [];
|
|
let maxConcurrency = 0;
|
|
|
|
// First pass: group requests
|
|
for (const metrics of requestsMetrics) {
|
|
const groupIndex = parallelGroups.findIndex((g) => metrics.startTime <= g.endTime);
|
|
|
|
if (groupIndex >= 0) {
|
|
const currentGroup = parallelGroups[groupIndex];
|
|
currentGroup.requests.push(metrics);
|
|
currentGroup.endTime = Math.min(currentGroup.endTime, metrics.endTime);
|
|
currentGroup.latency = Math.max(currentGroup.latency, metrics.latency);
|
|
maxConcurrency = Math.max(maxConcurrency, currentGroup.requests.length);
|
|
} else {
|
|
// Create a new parallel group
|
|
parallelGroups.push({
|
|
startTime: metrics.startTime,
|
|
endTime: metrics.endTime,
|
|
latency: metrics.latency,
|
|
requests: [metrics],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Second pass: recalculate max latency for all groups (except the last)
|
|
// The max latency should only be considered up until the startTime of the next group
|
|
for (let i = 0; i < parallelGroups.length - 1; i++) {
|
|
const currentGroup = parallelGroups[i];
|
|
let maxGroupLatency = 0;
|
|
|
|
for (const metrics of currentGroup.requests) {
|
|
let currentLatency = metrics.latency;
|
|
|
|
if ((metrics.startTime + metrics.latency) > currentGroup.endTime) {
|
|
currentLatency = currentGroup.endTime - metrics.startTime;
|
|
}
|
|
maxGroupLatency = Math.max(maxGroupLatency, currentLatency);
|
|
}
|
|
|
|
currentGroup.latency = maxGroupLatency;
|
|
}
|
|
|
|
return {parallelGroups, maxConcurrency};
|
|
}
|
|
|
|
calculateAverageSpeedWithCategories = (
|
|
parallelGroups: ParallelGroup[],
|
|
elapsedTimeInSeconds: number, // Observed total elapsed time in seconds
|
|
): { averageSpeedMbps: number; effectiveLatency: number } => {
|
|
// Step 1: Calculate total data size in bits
|
|
const totalDataBits = parallelGroups.reduce((sum, group) => {
|
|
return sum + group.requests.reduce((groupSum, req) => groupSum + (req.compressedSize * 8), 0);
|
|
}, 0);
|
|
|
|
// Step 2: Calculate effective latency (in ms)
|
|
const effectiveLatency = parallelGroups.reduce((sum, group) => sum + group.latency, 0);
|
|
|
|
// Step 3: Calculate data transfer time
|
|
const dataTransferTime = elapsedTimeInSeconds - (effectiveLatency / 1000);
|
|
|
|
// Handle edge case: if data transfer time is zero or negative.
|
|
if (dataTransferTime <= 0) {
|
|
return {averageSpeedMbps: 0, effectiveLatency};
|
|
}
|
|
|
|
// Step 4: Calculate average speed
|
|
const averageSpeedBps = totalDataBits / dataTransferTime; // Speed in bps
|
|
const averageSpeedMbps = averageSpeedBps / 1_000_000; // Convert to Mbps
|
|
|
|
return {
|
|
averageSpeedMbps,
|
|
effectiveLatency,
|
|
};
|
|
};
|
|
|
|
incrementRequestCount(groupLabel: RequestGroupLabel) {
|
|
this.initTrackGroup(groupLabel);
|
|
|
|
const group = this.requestGroups.get(groupLabel)!;
|
|
group.activeCount += 1;
|
|
}
|
|
|
|
decrementRequestCount(groupLabel: RequestGroupLabel) {
|
|
const group = this.requestGroups.get(groupLabel);
|
|
if (group) {
|
|
group.activeCount -= 1;
|
|
|
|
if (group.activeCount <= 0 && !group.completionFlag) {
|
|
this.clearCompletionTimer(groupLabel);
|
|
const latency = this.getAverageLatency(groupLabel);
|
|
group.completionTimer = setTimeout(() => {
|
|
if (this.allRequestsCompleted(groupLabel)) {
|
|
group.completionFlag = true;
|
|
this.handleRequestCompletion(groupLabel);
|
|
this.clearCompletionTimer(groupLabel);
|
|
}
|
|
}, latency);
|
|
}
|
|
}
|
|
}
|
|
|
|
clearCompletionTimer(groupLabel: RequestGroupLabel) {
|
|
const group = this.requestGroups.get(groupLabel);
|
|
if (group?.completionTimer) {
|
|
clearTimeout(group.completionTimer);
|
|
group.completionTimer = undefined;
|
|
}
|
|
}
|
|
|
|
handleRequestCompletion(groupLabel: RequestGroupLabel) {
|
|
const group = this.requestGroups.get(groupLabel);
|
|
if (group) {
|
|
const duration = Date.now() - group.startTime;
|
|
|
|
logDebug(`Group "${groupLabel}" completed.`);
|
|
this.sendTelemetryEvent(groupLabel, group, duration);
|
|
|
|
this.requestGroups.delete(groupLabel);
|
|
}
|
|
}
|
|
|
|
allRequestsCompleted(groupLabel: RequestGroupLabel): boolean {
|
|
const group = this.requestGroups.get(groupLabel);
|
|
return group ? group.activeCount <= 0 : true;
|
|
}
|
|
|
|
sendTelemetryEvent(groupLabel: RequestGroupLabel, groupData: GroupData, duration: number) {
|
|
const {totalCompressedSize, totalSize, urls: groupedUrls} = groupData;
|
|
const urls = Object.keys(groupedUrls);
|
|
const urlData = Object.entries(groupedUrls);
|
|
const dupe = urlData.filter((u) => u[1].count > 1);
|
|
const latency = this.getAverageLatency(groupLabel);
|
|
const {parallelGroups, maxConcurrency} = this.categorizeRequests(groupLabel);
|
|
const elapsedTimeInSeconds = duration / 1000;
|
|
const {averageSpeedMbps, effectiveLatency} = this.calculateAverageSpeedWithCategories(parallelGroups, elapsedTimeInSeconds);
|
|
|
|
logInfo(`Telemetry Event on ${Platform.OS} for Server ${this.apiClient.baseUrl}
|
|
Group: "${groupLabel}"
|
|
Total Requests: ${urls.length} URLs
|
|
Max Concurrency: ${maxConcurrency}
|
|
Parallel Groups: ${parallelGroups.length}
|
|
Requests in Each Group: ${parallelGroups.map((g) => g.requests.length).join(', ')}
|
|
Total Compressed Size: ${getFormattedFileSize(groupData.totalCompressedSize)}
|
|
Total Size: ${getFormattedFileSize(groupData.totalSize)}
|
|
Elapsed Time: ${elapsedTimeInSeconds} seconds (${duration} ms)
|
|
Average Request Latency: ${latency} ms
|
|
Effective Latency: ${effectiveLatency} ms
|
|
Average Speed: ${averageSpeedMbps.toFixed(4)} Mbps
|
|
`,
|
|
);
|
|
|
|
if (dupe.length) {
|
|
logDebug(`Duplicate URLs:\n${dupe.map((d, i) => `${i + 1} - ${d[0]} ${JSON.stringify(d[1])}`).join('\n')}`);
|
|
}
|
|
|
|
const {collectNetworkRequestData} = PerformanceMetricsManager;
|
|
|
|
const commonArguments = {serverUrl: this.apiClient.baseUrl, groupLabel};
|
|
const metricsData: Array<[NetworkRequestMetrics, number]> = [
|
|
[NetworkRequestMetrics.AverageSpeed, averageSpeedMbps],
|
|
[NetworkRequestMetrics.EffectiveLatency, effectiveLatency],
|
|
[NetworkRequestMetrics.ElapsedTime, elapsedTimeInSeconds],
|
|
[NetworkRequestMetrics.Latency, latency],
|
|
[NetworkRequestMetrics.TotalCompressedSize, totalCompressedSize],
|
|
[NetworkRequestMetrics.TotalRequests, urls.length],
|
|
[NetworkRequestMetrics.TotalSequentialRequests, parallelGroups.length],
|
|
[NetworkRequestMetrics.TotalSize, totalSize],
|
|
];
|
|
|
|
metricsData.forEach(([metric, value]) => {
|
|
collectNetworkRequestData(metric, value, commonArguments);
|
|
});
|
|
|
|
// Send metrics for each parallel group
|
|
parallelGroups.forEach((group) => {
|
|
collectNetworkRequestData(NetworkRequestMetrics.TotalParallelRequests, group.requests.length, commonArguments);
|
|
});
|
|
}
|
|
|
|
buildRequestOptions(options: ClientOptions): RequestOptions {
|
|
const requestOptions: RequestOptions = {
|
|
body: options.body,
|
|
headers: this.getRequestHeaders(options.method!.toLowerCase()),
|
|
};
|
|
if (options.noRetry) {
|
|
requestOptions.retryPolicyConfiguration = {retryLimit: 0};
|
|
}
|
|
if (options.timeoutInterval) {
|
|
requestOptions.timeoutInterval = options.timeoutInterval;
|
|
}
|
|
if (options.headers) {
|
|
requestOptions.headers = {...requestOptions.headers, ...options.headers};
|
|
}
|
|
return requestOptions;
|
|
}
|
|
|
|
doFetchWithTracking = async (url: string, options: ClientOptions, returnDataOnly = true) => {
|
|
let request;
|
|
const {groupLabel} = options;
|
|
const method = options.method?.toLowerCase();
|
|
switch (method) {
|
|
case 'get': request = this.apiClient!.get;
|
|
break;
|
|
case 'put': request = this.apiClient!.put;
|
|
break;
|
|
case 'post': request = this.apiClient!.post;
|
|
break;
|
|
case 'patch': request = this.apiClient!.patch;
|
|
break;
|
|
case 'delete': request = this.apiClient!.delete;
|
|
break;
|
|
default:
|
|
return {error: new ClientError(this.apiClient.baseUrl, {
|
|
message: 'Invalid request method',
|
|
intl: defineMessage({
|
|
id: 'mobile.request.invalid_request_method',
|
|
defaultMessage: 'Invalid request method',
|
|
}),
|
|
url,
|
|
})};
|
|
}
|
|
|
|
if (groupLabel && CollectNetworkMetrics) {
|
|
this.incrementRequestCount(groupLabel);
|
|
}
|
|
|
|
const performanceRequestId = NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
|
|
|
|
let response: ClientResponse;
|
|
try {
|
|
response = await request!(url, this.buildRequestOptions(options));
|
|
} catch (error) {
|
|
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, performanceRequestId);
|
|
const response_error = error as ClientError;
|
|
const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined;
|
|
logDebug('doFetchWithTracking: request failed', 'method', method, 'status_code', status_code, 'url', cleanUrlForLogging(this.apiClient.baseUrl, url));
|
|
throw new ClientError(this.apiClient.baseUrl, {
|
|
message: 'Received invalid response from the server.',
|
|
intl: defineMessage({
|
|
id: 'mobile.request.invalid_response',
|
|
defaultMessage: 'Received invalid response from the server.',
|
|
}),
|
|
url,
|
|
details: response_error,
|
|
headers: response_error.headers ? response_error.headers : undefined,
|
|
status_code,
|
|
});
|
|
} finally {
|
|
if (groupLabel && CollectNetworkMetrics) {
|
|
this.decrementRequestCount(groupLabel);
|
|
}
|
|
}
|
|
const headers: ClientHeaders = response.headers || {};
|
|
if (groupLabel && CollectNetworkMetrics) {
|
|
this.trackRequest(groupLabel, url, response.metrics);
|
|
}
|
|
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, performanceRequestId, response.metrics);
|
|
const serverVersion = semverFromServerVersion(
|
|
headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()],
|
|
);
|
|
const hasCacheControl = Boolean(
|
|
headers[ClientConstants.HEADER_CACHE_CONTROL] || headers[ClientConstants.HEADER_CACHE_CONTROL.toLowerCase()],
|
|
);
|
|
if (serverVersion && !hasCacheControl && this.serverVersion !== serverVersion) {
|
|
this.serverVersion = serverVersion;
|
|
DeviceEventEmitter.emit(Events.SERVER_VERSION_CHANGED, {serverUrl: this.apiClient.baseUrl, serverVersion});
|
|
}
|
|
|
|
const bearerToken = headers[ClientConstants.HEADER_TOKEN] || headers[ClientConstants.HEADER_TOKEN.toLowerCase()];
|
|
if (bearerToken) {
|
|
const existingSharedPassword = this.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET];
|
|
this.setClientCredentials(bearerToken, existingSharedPassword);
|
|
}
|
|
|
|
if (response.ok) {
|
|
return returnDataOnly ? (response.data || {}) : response;
|
|
}
|
|
|
|
throw new ClientError(this.apiClient.baseUrl, {
|
|
message: response.data?.message as string || `Response with status code ${response.code}`,
|
|
intl: isPreauthRejection(response.code, headers) ? preauthSecretInvalidMessage : undefined,
|
|
server_error_id: response.data?.id as string,
|
|
status_code: response.code,
|
|
url,
|
|
headers,
|
|
});
|
|
};
|
|
}
|