mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 02:18:56 +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
186 lines
7.6 KiB
TypeScript
186 lines
7.6 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||
// See LICENSE.txt for license information.
|
||
|
||
import Emm from '@mattermost/react-native-emm';
|
||
import {
|
||
type APIClientErrorEvent,
|
||
type APIClientErrorEventHandler,
|
||
getOrCreateAPIClient,
|
||
RetryTypes,
|
||
type APIClientConfiguration,
|
||
} from '@mattermost/react-native-network-client';
|
||
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application';
|
||
import {modelName, osName, osVersion} from 'expo-device';
|
||
import {defineMessages} from 'react-intl';
|
||
import {Alert, DeviceEventEmitter} from 'react-native';
|
||
import urlParse from 'url-parse';
|
||
|
||
import LocalConfig from '@assets/config.json';
|
||
import {Client} from '@client/rest';
|
||
import * as ClientConstants from '@client/rest/constants';
|
||
import ClientError from '@client/rest/error';
|
||
import {CERTIFICATE_ERRORS} from '@constants/network';
|
||
import ManagedApp from '@init/managed_app';
|
||
import {toMilliseconds} from '@utils/datetime';
|
||
import {getIntlShape} from '@utils/general';
|
||
import {logDebug, logError} from '@utils/log';
|
||
import {getCSRFFromCookie} from '@utils/security';
|
||
import {isSecureUrl} from '@utils/url';
|
||
|
||
const CLIENT_CERTIFICATE_IMPORT_ERROR_CODES = [-103, -104, -105, -108];
|
||
const CLIENT_CERTIFICATE_MISSING_ERROR_CODE = -200;
|
||
const SERVER_CERTIFICATE_INVALID = -299;
|
||
const SERVER_TRUST_EVALUATION_FAILED = -298;
|
||
let showingServerTrustAlert = false;
|
||
|
||
const messages = defineMessages({
|
||
invalidSslTitle: {
|
||
id: 'server.invalid.certificate.title',
|
||
defaultMessage: 'Invalid SSL certificate',
|
||
},
|
||
invalidSslDescription: {
|
||
id: 'server.invalid.certificate.description',
|
||
defaultMessage: 'The certificate for this server is invalid.\nYou might be connecting to a server that is pretending to be “{hostname}” which could put your confidential information at risk.',
|
||
},
|
||
invalidPinningTitle: {
|
||
id: 'server.invalid.pinning.title',
|
||
defaultMessage: 'Invalid pinned SSL certificate',
|
||
},
|
||
});
|
||
|
||
class NetworkManagerSingleton {
|
||
private clients: Record<string, Client> = {};
|
||
|
||
private intl = getIntlShape();
|
||
|
||
private DEFAULT_CONFIG: APIClientConfiguration = {
|
||
headers: {
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
...LocalConfig.CustomRequestHeaders,
|
||
},
|
||
sessionConfiguration: {
|
||
allowsCellularAccess: true,
|
||
waitsForConnectivity: false,
|
||
httpMaximumConnectionsPerHost: 100,
|
||
cancelRequestsOnUnauthorized: true,
|
||
collectMetrics: true,
|
||
},
|
||
retryPolicyConfiguration: {
|
||
type: RetryTypes.EXPONENTIAL_RETRY,
|
||
retryLimit: 3,
|
||
exponentialBackoffBase: 2,
|
||
exponentialBackoffScale: 0.5,
|
||
},
|
||
requestAdapterConfiguration: {
|
||
bearerAuthTokenResponseHeader: 'token',
|
||
enableSessionAttributes: true,
|
||
},
|
||
};
|
||
|
||
public init = async (serverCredentials: ServerCredential[]) => {
|
||
logDebug('NetworkManager: Initializing');
|
||
for await (const {serverUrl, token, preauthSecret} of serverCredentials) {
|
||
try {
|
||
await this.createClient(serverUrl, token, preauthSecret);
|
||
} catch (error) {
|
||
logError('NetworkManager init error', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
public invalidateClient = (serverUrl: string) => {
|
||
this.clients[serverUrl]?.invalidate();
|
||
delete this.clients[serverUrl];
|
||
};
|
||
|
||
public getClient = (serverUrl: string) => {
|
||
const client = this.clients[serverUrl];
|
||
if (!client) {
|
||
throw new Error(`${serverUrl} client not found`);
|
||
}
|
||
|
||
return client;
|
||
};
|
||
|
||
public createClient = async (serverUrl: string, bearerToken?: string, preauthSecret?: string) => {
|
||
const config = await this.buildConfig(serverUrl, preauthSecret);
|
||
|
||
try {
|
||
const {client} = await getOrCreateAPIClient(serverUrl, config, this.clientErrorEventHandler);
|
||
const csrfToken = await getCSRFFromCookie(serverUrl);
|
||
|
||
// Pass preauthSecret explicitly to constructor to match ClientBase behavior
|
||
this.clients[serverUrl] = new Client(client, serverUrl, bearerToken, csrfToken, preauthSecret);
|
||
} catch (error) {
|
||
throw new ClientError(serverUrl, {
|
||
message: 'Can’t find this server. Check spelling and URL format.',
|
||
intl: {
|
||
id: 'apps.error.network.no_server',
|
||
defaultMessage: 'Can’t find this server. Check spelling and URL format.',
|
||
},
|
||
url: serverUrl,
|
||
details: error,
|
||
});
|
||
}
|
||
|
||
return this.clients[serverUrl];
|
||
};
|
||
|
||
private buildConfig = async (serverUrl: string, preauthSecret?: string) => {
|
||
const userAgent = `Mattermost Mobile/${nativeApplicationVersion}+${nativeBuildVersion} (${osName}; ${osVersion}; ${modelName})`;
|
||
const managedConfig = ManagedApp.enabled ? Emm.getManagedConfig<ManagedConfig>() : undefined;
|
||
const headers: Record<string, string> = {
|
||
[ClientConstants.HEADER_USER_AGENT]: userAgent,
|
||
...(preauthSecret && isSecureUrl(serverUrl) ? {[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]: preauthSecret} : {}),
|
||
...this.DEFAULT_CONFIG.headers,
|
||
};
|
||
|
||
const config = {
|
||
...this.DEFAULT_CONFIG,
|
||
sessionConfiguration: {
|
||
...this.DEFAULT_CONFIG.sessionConfiguration,
|
||
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
|
||
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
|
||
waitsForConnectivity: managedConfig?.useVPN === 'true',
|
||
collectMetrics: true,
|
||
},
|
||
headers,
|
||
};
|
||
|
||
return config;
|
||
};
|
||
|
||
private clientErrorEventHandler: APIClientErrorEventHandler = (event: APIClientErrorEvent) => {
|
||
if (CLIENT_CERTIFICATE_IMPORT_ERROR_CODES.includes(event.errorCode)) {
|
||
DeviceEventEmitter.emit(CERTIFICATE_ERRORS.CLIENT_CERTIFICATE_IMPORT_ERROR, event.serverUrl);
|
||
} else if (CLIENT_CERTIFICATE_MISSING_ERROR_CODE === event.errorCode) {
|
||
DeviceEventEmitter.emit(CERTIFICATE_ERRORS.CLIENT_CERTIFICATE_MISSING, event.serverUrl);
|
||
} else if (SERVER_CERTIFICATE_INVALID === event.errorCode) {
|
||
logDebug('Invalid SSL certificate:', event.errorDescription);
|
||
const parsed = urlParse(event.serverUrl);
|
||
Alert.alert(
|
||
this.intl.formatMessage(messages.invalidSslTitle),
|
||
this.intl.formatMessage(messages.invalidSslDescription, {hostname: parsed.hostname}),
|
||
);
|
||
} else if (SERVER_TRUST_EVALUATION_FAILED === event.errorCode && !showingServerTrustAlert) {
|
||
logDebug('Invalid SSL Pinning:', event.errorDescription);
|
||
showingServerTrustAlert = true;
|
||
Alert.alert(
|
||
this.intl.formatMessage(messages.invalidPinningTitle),
|
||
event.errorDescription,
|
||
[{
|
||
text: this.intl.formatMessage({id: 'server_upgrade.dismiss', defaultMessage: 'Dismiss'}),
|
||
onPress: () => {
|
||
setTimeout(() => {
|
||
showingServerTrustAlert = false;
|
||
}, toMilliseconds({hours: 23}));
|
||
},
|
||
}],
|
||
);
|
||
}
|
||
};
|
||
}
|
||
|
||
const NetworkManager = new NetworkManagerSingleton();
|
||
export default NetworkManager;
|