From a7d33311c9ca4e7bf31b865356797dbdb6c82c71 Mon Sep 17 00:00:00 2001 From: isstuev Date: Tue, 18 Nov 2025 16:26:28 +0100 Subject: [PATCH 1/2] Private mode --- configs/app/app.ts | 4 ++ configs/app/features/account.ts | 8 ++- configs/app/features/addressProfileAPI.ts | 3 +- configs/app/features/addressVerification.ts | 3 +- configs/app/features/adsBanner.ts | 8 +++ configs/app/features/adsText.ts | 3 +- configs/app/features/blockchainInteraction.ts | 2 + configs/app/features/deFiDropdown.ts | 18 ++++--- configs/app/features/googleAnalytics.ts | 3 +- configs/app/features/growthBook.ts | 3 +- configs/app/features/marketplace.ts | 8 ++- configs/app/features/mixpanel.ts | 3 +- configs/app/features/publicTagsSubmission.ts | 3 +- configs/app/features/rewards.ts | 3 +- configs/app/features/rollbar.ts | 2 +- configs/app/features/web3Wallet.ts | 3 +- docs/PULL_REQUEST_TEMPLATE.md | 1 + lib/cookies.ts | 37 ++++++++++++- lib/recentSearchKeywords.ts | 6 ++- lib/utils/stripUtmParams.ts | 53 +++++++++++++++++++ middleware.ts | 3 +- nextjs/csp/generateCspPolicy.ts | 19 +++---- nextjs/csp/index.ts | 31 +++++++++-- nextjs/csp/policies/app.ts | 14 +++-- nextjs/getServerSideProps/handlers.ts | 5 +- nextjs/middlewares/appProfile.ts | 20 +++++++ nextjs/middlewares/index.ts | 1 + toolkit/chakra/link.tsx | 13 +++-- .../methods/ContractVerificationSourcify.tsx | 9 ++++ ui/shared/nft/NftHtml.tsx | 12 +++++ ui/shared/reCaptcha/ReCaptcha.tsx | 2 +- ui/shared/reCaptcha/useReCaptcha.tsx | 12 +++-- 32 files changed, 265 insertions(+), 50 deletions(-) create mode 100644 lib/utils/stripUtmParams.ts create mode 100644 nextjs/middlewares/appProfile.ts diff --git a/configs/app/app.ts b/configs/app/app.ts index dae2c453d1..fe466928d5 100644 --- a/configs/app/app.ts +++ b/configs/app/app.ts @@ -1,3 +1,5 @@ +import * as cookies from 'lib/cookies'; + import { getEnvValue } from './utils'; const appPort = getEnvValue('NEXT_PUBLIC_APP_PORT'); @@ -13,6 +15,7 @@ const isDev = getEnvValue('NEXT_PUBLIC_APP_ENV') === 'development'; const isReview = getEnvValue('NEXT_PUBLIC_APP_ENV') === 'review'; const isPw = getEnvValue('NEXT_PUBLIC_APP_INSTANCE') === 'pw'; const spriteHash = getEnvValue('NEXT_PUBLIC_ICON_SPRITE_HASH'); +const appProfile = cookies.get(cookies.NAMES.APP_PROFILE); const app = Object.freeze({ isDev, @@ -24,6 +27,7 @@ const app = Object.freeze({ baseUrl, useProxy: getEnvValue('NEXT_PUBLIC_USE_NEXT_JS_PROXY') === 'true', spriteHash, + appProfile, }); export default app; diff --git a/configs/app/features/account.ts b/configs/app/features/account.ts index 82ae3b458b..da71c9b4ed 100644 --- a/configs/app/features/account.ts +++ b/configs/app/features/account.ts @@ -1,12 +1,18 @@ import type { Feature } from './types'; +import app from '../app'; import services from '../services'; import { getEnvValue } from '../utils'; const title = 'My account'; const config: Feature<{ isEnabled: true; recaptchaSiteKey: string }> = (() => { - if (getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' && services.reCaptchaV2.siteKey) { + + if ( + app.appProfile !== 'private' && + getEnvValue('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED') === 'true' && + services.reCaptchaV2.siteKey + ) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/addressProfileAPI.ts b/configs/app/features/addressProfileAPI.ts index e46301ee6f..7e24c383b1 100644 --- a/configs/app/features/addressProfileAPI.ts +++ b/configs/app/features/addressProfileAPI.ts @@ -1,6 +1,7 @@ import type { Feature } from './types'; import type { AddressProfileAPIConfig } from 'types/client/addressProfileAPIConfig'; +import app from '../app'; import { getEnvValue, parseEnvJson } from '../utils'; const value = parseEnvJson(getEnvValue('NEXT_PUBLIC_ADDRESS_USERNAME_TAG')); @@ -24,7 +25,7 @@ const config: Feature<{ tagBgColor?: string; tagTextColor?: string; }> = (() => { - if (value && checkApiUrlTemplate(value.api_url_template)) { + if (app.appProfile !== 'private' && value && checkApiUrlTemplate(value.api_url_template)) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/addressVerification.ts b/configs/app/features/addressVerification.ts index bd83be12b4..9f3348dc6b 100644 --- a/configs/app/features/addressVerification.ts +++ b/configs/app/features/addressVerification.ts @@ -1,13 +1,14 @@ import type { Feature } from './types'; import apis from '../apis'; +import app from '../app'; import account from './account'; import verifiedTokens from './verifiedTokens'; const title = 'Address verification in "My account"'; const config: Feature<{}> = (() => { - if (account.isEnabled && verifiedTokens.isEnabled && apis.admin) { + if (app.appProfile !== 'private' && account.isEnabled && verifiedTokens.isEnabled && apis.admin) { return Object.freeze({ title: 'Address verification in "My account"', isEnabled: true, diff --git a/configs/app/features/adsBanner.ts b/configs/app/features/adsBanner.ts index 8edc848469..86c8a6c48e 100644 --- a/configs/app/features/adsBanner.ts +++ b/configs/app/features/adsBanner.ts @@ -3,6 +3,7 @@ import type { AdButlerConfig } from 'types/client/adButlerConfig'; import { SUPPORTED_AD_BANNER_PROVIDERS } from 'types/client/adProviders'; import type { AdBannerProviders, AdBannerAdditionalProviders } from 'types/client/adProviders'; +import app from '../app'; import { getEnvValue, parseEnvJson } from '../utils'; const provider: AdBannerProviders = (() => { @@ -42,6 +43,13 @@ type AdsBannerFeaturePayload = AdsBannerFeatureProviderPayload & { }; const config: Feature = (() => { + if (app.appProfile === 'private') { + return Object.freeze({ + title, + isEnabled: false, + }); + } + if (provider === 'adbutler') { const desktopConfig = parseEnvJson(getEnvValue('NEXT_PUBLIC_AD_ADBUTLER_CONFIG_DESKTOP')); const mobileConfig = parseEnvJson(getEnvValue('NEXT_PUBLIC_AD_ADBUTLER_CONFIG_MOBILE')); diff --git a/configs/app/features/adsText.ts b/configs/app/features/adsText.ts index 82946f984f..19ec22d784 100644 --- a/configs/app/features/adsText.ts +++ b/configs/app/features/adsText.ts @@ -2,6 +2,7 @@ import type { Feature } from './types'; import { SUPPORTED_AD_TEXT_PROVIDERS } from 'types/client/adProviders'; import type { AdTextProviders } from 'types/client/adProviders'; +import app from '../app'; import { getEnvValue } from '../utils'; const provider: AdTextProviders = (() => { @@ -12,7 +13,7 @@ const provider: AdTextProviders = (() => { const title = 'Text ads'; const config: Feature<{ provider: AdTextProviders }> = (() => { - if (provider !== 'none') { + if (app.appProfile !== 'private' && provider !== 'none') { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/blockchainInteraction.ts b/configs/app/features/blockchainInteraction.ts index ce8709f6b6..22441ff81e 100644 --- a/configs/app/features/blockchainInteraction.ts +++ b/configs/app/features/blockchainInteraction.ts @@ -1,5 +1,6 @@ import type { Feature } from './types'; +import app from '../app'; import chain from '../chain'; import { getEnvValue } from '../utils'; import opSuperchain from './opSuperchain'; @@ -24,6 +25,7 @@ const config: Feature<{ walletConnect: { projectId: string } }> = (() => { const isOpSuperchain = opSuperchain.isEnabled; if ( + app.appProfile !== 'private' && (isSingleChain || isOpSuperchain) && walletConnectProjectId ) { diff --git a/configs/app/features/deFiDropdown.ts b/configs/app/features/deFiDropdown.ts index 62b8fdcd14..b992576932 100644 --- a/configs/app/features/deFiDropdown.ts +++ b/configs/app/features/deFiDropdown.ts @@ -1,21 +1,25 @@ import type { Feature } from './types'; import type { DeFiDropdownItem } from 'types/client/deFiDropdown'; +import app from '../app'; import { getEnvValue, parseEnvJson } from '../utils'; const items = parseEnvJson>(getEnvValue('NEXT_PUBLIC_DEFI_DROPDOWN_ITEMS')) || []; const title = 'DeFi dropdown'; -const config: Feature<{ items: Array }> = items.length > 0 ? - Object.freeze({ - title, - isEnabled: true, - items, - }) : - Object.freeze({ +const config: Feature<{ items: Array }> = (() => { + if (app.appProfile !== 'private' && items.length > 0) { + return Object.freeze({ + title, + isEnabled: true, + items, + }); + } + return Object.freeze({ title, isEnabled: false, }); +})(); export default config; diff --git a/configs/app/features/googleAnalytics.ts b/configs/app/features/googleAnalytics.ts index 4fe9a9bf9e..04fe8f3f11 100644 --- a/configs/app/features/googleAnalytics.ts +++ b/configs/app/features/googleAnalytics.ts @@ -1,5 +1,6 @@ import type { Feature } from './types'; +import app from '../app'; import { getEnvValue } from '../utils'; const propertyId = getEnvValue('NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID'); @@ -7,7 +8,7 @@ const propertyId = getEnvValue('NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID'); const title = 'Google analytics'; const config: Feature<{ propertyId: string }> = (() => { - if (propertyId) { + if (app.appProfile !== 'private' && propertyId) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/growthBook.ts b/configs/app/features/growthBook.ts index af672c5ac9..12506fab85 100644 --- a/configs/app/features/growthBook.ts +++ b/configs/app/features/growthBook.ts @@ -1,5 +1,6 @@ import type { Feature } from './types'; +import app from '../app'; import { getEnvValue } from '../utils'; const clientKey = getEnvValue('NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY'); @@ -7,7 +8,7 @@ const clientKey = getEnvValue('NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY'); const title = 'GrowthBook feature flagging and A/B testing'; const config: Feature<{ clientKey: string }> = (() => { - if (clientKey) { + if (app.appProfile !== 'private' && clientKey) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/marketplace.ts b/configs/app/features/marketplace.ts index 0f6785a552..a7cadca50d 100644 --- a/configs/app/features/marketplace.ts +++ b/configs/app/features/marketplace.ts @@ -2,6 +2,7 @@ import type { Feature } from './types'; import type { EssentialDappsConfig, MarketplaceTitles } from 'types/client/marketplace'; import apis from '../apis'; +import app from '../app'; import chain from '../chain'; import { getEnvValue, getExternalAssetFilePath, parseEnvJson } from '../utils'; import blockchainInteraction from './blockchainInteraction'; @@ -44,7 +45,12 @@ const config: Feature<( essentialDappsAdEnabled: boolean; titles: MarketplaceTitles; }> = (() => { - if (enabled === 'true' && chain.rpcUrls.length > 0 && submitFormUrl) { + if ( + app.appProfile !== 'private' && + chain.rpcUrls.length > 0 && + enabled === 'true' && + submitFormUrl + ) { const props = { submitFormUrl, categoriesUrl, diff --git a/configs/app/features/mixpanel.ts b/configs/app/features/mixpanel.ts index 4d1d72cf2b..8d6ab2269f 100644 --- a/configs/app/features/mixpanel.ts +++ b/configs/app/features/mixpanel.ts @@ -2,6 +2,7 @@ import type { Config } from 'mixpanel-browser'; import type { Feature } from './types'; +import app from '../app'; import { getEnvValue, parseEnvJson } from '../utils'; const projectToken = getEnvValue('NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN'); @@ -17,7 +18,7 @@ const configOverrides = (() => { const title = 'Mixpanel analytics'; const config: Feature<{ projectToken: string; configOverrides?: Partial }> = (() => { - if (projectToken) { + if (app.appProfile !== 'private' && projectToken) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/publicTagsSubmission.ts b/configs/app/features/publicTagsSubmission.ts index d5f5448628..7e52a0f473 100644 --- a/configs/app/features/publicTagsSubmission.ts +++ b/configs/app/features/publicTagsSubmission.ts @@ -1,13 +1,14 @@ import type { Feature } from './types'; import apis from '../apis'; +import app from '../app'; import services from '../services'; import addressMetadata from './addressMetadata'; const title = 'Public tag submission'; const config: Feature<{}> = (() => { - if (services.reCaptchaV2.siteKey && addressMetadata.isEnabled && apis.admin) { + if (app.appProfile !== 'private' && services.reCaptchaV2.siteKey && addressMetadata.isEnabled && apis.admin) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/rewards.ts b/configs/app/features/rewards.ts index 3b12d46fb8..e157253bd4 100644 --- a/configs/app/features/rewards.ts +++ b/configs/app/features/rewards.ts @@ -1,13 +1,14 @@ import type { Feature } from './types'; import apis from '../apis'; +import app from '../app'; import account from './account'; import blockchainInteraction from './blockchainInteraction'; const title = 'Rewards service integration'; const config: Feature<{}> = (() => { - if (apis.rewards && account.isEnabled && blockchainInteraction.isEnabled) { + if (app.appProfile !== 'private' && apis.rewards && account.isEnabled && blockchainInteraction.isEnabled) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/rollbar.ts b/configs/app/features/rollbar.ts index 276f782e4e..af85e22308 100644 --- a/configs/app/features/rollbar.ts +++ b/configs/app/features/rollbar.ts @@ -23,7 +23,7 @@ const config: Feature<{ instance: string | undefined; codeVersion: string | undefined; }> = (() => { - if (clientToken) { + if (app.appProfile !== 'private' && clientToken) { return Object.freeze({ title, isEnabled: true, diff --git a/configs/app/features/web3Wallet.ts b/configs/app/features/web3Wallet.ts index e54b1eaa10..88fed9122e 100644 --- a/configs/app/features/web3Wallet.ts +++ b/configs/app/features/web3Wallet.ts @@ -2,6 +2,7 @@ import type { Feature } from './types'; import { SUPPORTED_WALLETS } from 'types/client/wallets'; import type { WalletType } from 'types/client/wallets'; +import app from '../app'; import { getEnvValue, parseEnvJson } from '../utils'; const wallets = ((): Array | undefined => { @@ -22,7 +23,7 @@ const wallets = ((): Array | undefined => { const title = 'Web3 wallet integration (add token or network to the wallet)'; const config: Feature<{ wallets: Array; addToken: { isDisabled: boolean } }> = (() => { - if (wallets && wallets.length > 0) { + if (app.appProfile !== 'private' && wallets && wallets.length > 0) { return Object.freeze({ title, isEnabled: true, diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index d4378762bf..669ae93ed4 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -15,6 +15,7 @@ - [ ] I have tested these changes locally. - [ ] I added tests to cover any new functionality, following this [guide](./CONTRIBUTING.md#writing--running-tests) - [ ] Whenever I fix a bug, I include a regression test to ensure that the bug does not reappear silently. +- [ ] If I have added a feature or functionality that is not privacy-compliant (e.g., tracking, analytics, third-party services), I have disabled it for private mode. - [ ] If I have added, changed, renamed, or removed an environment variable - I updated the list of environment variables in the [documentation](ENVS.md) - I made the necessary changes to the validator script according to the [guide](./CONTRIBUTING.md#adding-new-env-variable) diff --git a/lib/cookies.ts b/lib/cookies.ts index 578d93bd43..6a8c22ee88 100644 --- a/lib/cookies.ts +++ b/lib/cookies.ts @@ -2,6 +2,9 @@ import Cookies from 'js-cookie'; import { isBrowser } from 'toolkit/utils/isBrowser'; +/** + * All cookie names that can be used in the application. + */ export enum NAMES { NAV_BAR_COLLAPSED = 'nav_bar_collapsed', API_TOKEN = '_explorer_key', @@ -21,8 +24,19 @@ export enum NAMES { HIDE_ADD_TO_WALLET_BUTTON = 'hide_add_to_wallet_button', UUID = 'uuid', SHOW_SCAM_TOKENS = 'show_scam_tokens', + APP_PROFILE = 'app_profile', } +/** + * Cookies that are disallowed in private mode. + * These cookies should not be set when app profile is 'private'. + */ +export const PRIVATE_MODE_DISALLOWED: ReadonlyArray = [ + NAMES.UUID, + NAMES.ADBLOCK_DETECTED, + NAMES.MIXPANEL_DEBUG, +]; + export function get(name?: NAMES | undefined | null, serverCookie?: string) { if (!isBrowser()) { return serverCookie ? getFromCookieString(serverCookie, name) : undefined; @@ -33,7 +47,28 @@ export function get(name?: NAMES | undefined | null, serverCookie?: string) { } } -export function set(name: NAMES, value: string, attributes: Cookies.CookieAttributes = {}) { +/** + * Checks if a cookie is disallowed in private mode. + */ +function isDisallowedInPrivateMode(name: NAMES): boolean { + return PRIVATE_MODE_DISALLOWED.includes(name); +} + +/** + * Checks if the app is currently in private mode by reading the APP_PROFILE cookie. + */ +function isPrivateMode(serverCookie?: string): boolean { + const appProfile = get(NAMES.APP_PROFILE, serverCookie); + return appProfile === 'private'; +} + +export function set(name: NAMES, value: string, attributes: Cookies.CookieAttributes = {}, serverCookie?: string) { + // Check if we're in private mode and this cookie is disallowed + if (isPrivateMode(serverCookie) && isDisallowedInPrivateMode(name)) { + // Don't set the cookie in private mode + return; + } + attributes.path = '/'; return Cookies.set(name, value, attributes); diff --git a/lib/recentSearchKeywords.ts b/lib/recentSearchKeywords.ts index c6ae761bd1..1cdebcb6f0 100644 --- a/lib/recentSearchKeywords.ts +++ b/lib/recentSearchKeywords.ts @@ -1,5 +1,6 @@ import { uniq } from 'es-toolkit'; +import config from 'configs/app'; import { isBrowser } from 'toolkit/utils/isBrowser'; const RECENT_KEYWORDS_LS_KEY = 'recent_search_keywords'; @@ -22,6 +23,9 @@ const parseKeywordsArray = (keywordsStr: string) => { }; export function saveToRecentKeywords(value: string) { + if (config.app.appProfile === 'private') { + return; + } if (!value) { return; } @@ -32,7 +36,7 @@ export function saveToRecentKeywords(value: string) { } export function getRecentSearchKeywords(input?: string) { - if (!isBrowser()) { + if (!isBrowser() || config.app.appProfile === 'private') { return []; } const keywordsStr = window.localStorage.getItem(RECENT_KEYWORDS_LS_KEY) || ''; diff --git a/lib/utils/stripUtmParams.ts b/lib/utils/stripUtmParams.ts new file mode 100644 index 0000000000..a4f8d0955d --- /dev/null +++ b/lib/utils/stripUtmParams.ts @@ -0,0 +1,53 @@ +import config from 'configs/app'; + +const UTM_PARAMS = [ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content' ]; + +/** + * Strips UTM parameters from a URL if app is in private mode + * @param url - The URL string to process + * @returns The URL with UTM parameters removed if in private mode, otherwise the original URL + */ +export default function stripUtmParams(url: string): string { + if (config.app.appProfile !== 'private') { + return url; + } + + try { + const urlObj = new URL(url); + UTM_PARAMS.forEach((param) => { + urlObj.searchParams.delete(param); + }); + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return original URL + return url; + } +} + +/** + * Conditionally adds UTM parameters to a URL only if not in private mode + * @param url - The URL string to process + * @param utmParams - Object with UTM parameters to add + * @returns The URL with UTM parameters added if not in private mode, otherwise the original URL + */ +export function addUtmParamsIfNotPrivate( + url: string, + utmParams: Record, +): string { + if (config.app.appProfile === 'private') { + return url; + } + + try { + const urlObj = new URL(url); + Object.entries(utmParams).forEach(([ key, value ]) => { + if (key.startsWith('utm_')) { + urlObj.searchParams.append(key, value); + } + }); + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return original URL + return url; + } +} diff --git a/middleware.ts b/middleware.ts index ea2eb843a2..d28e6f5cdc 100644 --- a/middleware.ts +++ b/middleware.ts @@ -19,13 +19,14 @@ export async function middleware(req: NextRequest) { const res = NextResponse.next(); + middlewares.appProfile(req, res); middlewares.colorTheme(req, res); middlewares.addressFormat(req, res); middlewares.scamTokens(req, res); const end = Date.now(); - const cspHeader = await csp.get(); + const cspHeader = await csp.get(req); res.headers.append('Content-Security-Policy', cspHeader); res.headers.append('Server-Timing', `middleware;dur=${ end - start }`); diff --git a/nextjs/csp/generateCspPolicy.ts b/nextjs/csp/generateCspPolicy.ts index e3782e17e3..083064b832 100644 --- a/nextjs/csp/generateCspPolicy.ts +++ b/nextjs/csp/generateCspPolicy.ts @@ -1,28 +1,29 @@ import * as descriptors from './policies'; import { makePolicyString, mergeDescriptors } from './utils'; -function generateCspPolicy() { +function generateCspPolicy(isPrivateMode = false) { const policyDescriptor = mergeDescriptors( - descriptors.app(), - descriptors.ad(), + descriptors.app(isPrivateMode), + // Exclude tracking/analytics sources in private mode + isPrivateMode ? {} : descriptors.ad(), descriptors.cloudFlare(), descriptors.flashblocks(), descriptors.gasHawk(), - descriptors.googleAnalytics(), + isPrivateMode ? {} : descriptors.googleAnalytics(), descriptors.googleFonts(), descriptors.googleReCaptcha(), - descriptors.growthBook(), + isPrivateMode ? {} : descriptors.growthBook(), descriptors.helia(), - descriptors.marketplace(), + isPrivateMode ? {} : descriptors.marketplace(), descriptors.megaEth(), - descriptors.mixpanel(), + isPrivateMode ? {} : descriptors.mixpanel(), descriptors.monaco(), descriptors.multichain(), - descriptors.rollbar(), + isPrivateMode ? {} : descriptors.rollbar(), descriptors.rollup(), descriptors.safe(), descriptors.usernameApi(), - descriptors.walletConnect(), + isPrivateMode ? {} : descriptors.walletConnect(), descriptors.zetachain(), ); diff --git a/nextjs/csp/index.ts b/nextjs/csp/index.ts index b7715b015a..72896d4090 100644 --- a/nextjs/csp/index.ts +++ b/nextjs/csp/index.ts @@ -1,19 +1,40 @@ +import type { NextRequest } from 'next/server'; + import appConfig from 'configs/app'; import * as essentialDappsChainsConfig from 'configs/essential-dapps-chains/config.edge'; import * as multichainConfig from 'configs/multichain/config.edge'; +import * as cookiesLib from 'lib/cookies'; import generateCspPolicy from './generateCspPolicy'; const marketplaceFeature = appConfig.features.marketplace; -let cspPolicy: string | undefined = undefined; +let cspPolicies: { 'private': string; 'default': string } | undefined = undefined; -export async function get() { - if (!cspPolicy) { +async function initializeCspPolicies() { + if (!cspPolicies) { appConfig.features.opSuperchain.isEnabled && await multichainConfig.load(); marketplaceFeature.isEnabled && marketplaceFeature.essentialDapps && await essentialDappsChainsConfig.load(); - cspPolicy = generateCspPolicy(); + + // Generate and cache both policies upfront + cspPolicies = { + 'private': generateCspPolicy(true), + 'default': generateCspPolicy(false), + }; } +} + +export async function get(req?: NextRequest): Promise { + await initializeCspPolicies(); + + // Get appProfile from request (header, query param, or cookie) + const appProfile = req ? ( + req.headers.get('x-app-profile') || + req.nextUrl.searchParams.get('app-profile') || + cookiesLib.getFromCookieString(req.headers.get('cookie') || '', cookiesLib.NAMES.APP_PROFILE) + ) : undefined; + + const isPrivateMode = appProfile === 'private'; - return cspPolicy; + return isPrivateMode ? cspPolicies!.private : cspPolicies!.default; } diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index 2f90845915..c2c1ca8ff8 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -21,7 +21,7 @@ const externalFontsDomains = (() => { } catch (error) {} })(); -export function app(): CspDev.DirectiveDescriptor { +export function app(isPrivateMode = false): CspDev.DirectiveDescriptor { return { 'default-src': [ // KEY_WORDS.NONE, @@ -119,10 +119,14 @@ export function app(): CspDev.DirectiveDescriptor { KEY_WORDS.NONE, ], - 'frame-src': [ - // could be a marketplace app or NFT media (html-page) - '*', - ], + // Restrict frame-src in private mode to prevent iframe tracking + // In normal mode, frame-src is also set by marketplace.ts when marketplace is enabled + ...(isPrivateMode ? {} as CspDev.DirectiveDescriptor : { + 'frame-src': [ + // could be a marketplace app or NFT media (html-page) + '*', + ], + }), 'frame-ancestors': [ KEY_WORDS.SELF, diff --git a/nextjs/getServerSideProps/handlers.ts b/nextjs/getServerSideProps/handlers.ts index 26971e5052..33b2a2bf43 100644 --- a/nextjs/getServerSideProps/handlers.ts +++ b/nextjs/getServerSideProps/handlers.ts @@ -25,6 +25,7 @@ export interface Props { export const base = async ({ req, res, query }: GetServerSidePropsContext): Promise>> => { + const appProfile = req.headers?.['x-app-profile'] || cookies.getFromCookieString(req.headers.cookie || '', cookies.NAMES.APP_PROFILE); const adBannerProvider = (() => { if (adBannerFeature.isEnabled) { if ('additionalProvider' in adBannerFeature && adBannerFeature.additionalProvider) { @@ -39,12 +40,12 @@ Promise>> => { })(); let uuid = cookies.getFromCookieString(req.headers.cookie || '', cookies.NAMES.UUID); - if (!uuid) { + if (!uuid && appProfile !== 'private') { uuid = crypto.randomUUID(); res.setHeader('Set-Cookie', `${ cookies.NAMES.UUID }=${ uuid }`); } - const isTrackingDisabled = process.env.DISABLE_TRACKING === 'true'; + const isTrackingDisabled = process.env.DISABLE_TRACKING === 'true' || appProfile === 'private'; if (!isTrackingDisabled) { const isRealUser = isLikelyHumanBrowser(req) && !isKnownBotRequest(req); diff --git a/nextjs/middlewares/appProfile.ts b/nextjs/middlewares/appProfile.ts new file mode 100644 index 0000000000..412a96beb0 --- /dev/null +++ b/nextjs/middlewares/appProfile.ts @@ -0,0 +1,20 @@ +import type { NextRequest, NextResponse } from 'next/server'; + +import * as cookiesLib from 'lib/cookies'; + +const APP_PROFILE_HEADER = 'x-app-profile'; +const APP_PROFILE_QUERY_PARAM = 'app-profile'; +const PRIVATE_PROFILE_VALUE = 'private'; + +export default function appProfileMiddleware(req: NextRequest, res: NextResponse) { + const headerValue = req.headers.get(APP_PROFILE_HEADER); + // for testing purposes + const queryValue = req.nextUrl.searchParams.get(APP_PROFILE_QUERY_PARAM); + + const profileValue = headerValue || queryValue; + if (profileValue === PRIVATE_PROFILE_VALUE) { + res.cookies.set(cookiesLib.NAMES.APP_PROFILE, PRIVATE_PROFILE_VALUE, { path: '/' }); + } else { + res.cookies.delete(cookiesLib.NAMES.APP_PROFILE); + } +} diff --git a/nextjs/middlewares/index.ts b/nextjs/middlewares/index.ts index 763e81a7fe..81511932e1 100644 --- a/nextjs/middlewares/index.ts +++ b/nextjs/middlewares/index.ts @@ -2,3 +2,4 @@ export { account } from './account'; export { default as colorTheme } from './colorTheme'; export { default as addressFormat } from './addressFormat'; export { default as scamTokens } from './scamTokens'; +export { default as appProfile } from './appProfile'; diff --git a/toolkit/chakra/link.tsx b/toolkit/chakra/link.tsx index cd558ec11e..fb9751468f 100644 --- a/toolkit/chakra/link.tsx +++ b/toolkit/chakra/link.tsx @@ -5,6 +5,7 @@ import type { LinkProps as NextLinkProps } from 'next/link'; import React from 'react'; import ArrowIcon from 'icons/link_external.svg'; +import stripUtmParams from 'lib/utils/stripUtmParams'; import { Skeleton } from './skeleton'; @@ -54,10 +55,13 @@ export const Link = React.forwardRef( const { external, loading, href, children, disabled, noIcon, iconColor, ...rest } = chakra; if (external) { + // Strip UTM parameters from external links if in private mode + const processedHref = typeof href === 'string' ? stripUtmParams(href) : href; + return ( } asChild> ( function LinkOverlay(props, ref) { const { chakra, next } = splitProps(props); - const { children, external, ...rest } = chakra; + const { children, external, href, ...rest } = chakra; if (external) { + // Strip UTM parameters from external links if in private mode + const processedHref = typeof href === 'string' ? stripUtmParams(href) : href; + return ( - + { children } ); diff --git a/ui/contractVerification/methods/ContractVerificationSourcify.tsx b/ui/contractVerification/methods/ContractVerificationSourcify.tsx index 4913ab2539..e9c0afe509 100644 --- a/ui/contractVerification/methods/ContractVerificationSourcify.tsx +++ b/ui/contractVerification/methods/ContractVerificationSourcify.tsx @@ -11,6 +11,15 @@ const ContractVerificationSourcify = () => { const { watch } = useFormContext(); const address = watch('address'); + // Disable iframe in private mode to prevent tracking + if (config.app.appProfile === 'private') { + return ( + +

This feature is disabled in private mode.

+
+ ); + } + const iframeUrl = `https://verify.sourcify.dev/widget?chainId=${ config.chain.id }&address=${ address }`; return ( diff --git a/ui/shared/nft/NftHtml.tsx b/ui/shared/nft/NftHtml.tsx index e5d64125ff..0f0ed709f6 100644 --- a/ui/shared/nft/NftHtml.tsx +++ b/ui/shared/nft/NftHtml.tsx @@ -1,6 +1,7 @@ import { chakra } from '@chakra-ui/react'; import React from 'react'; +import config from 'configs/app'; import { LinkOverlay } from 'toolkit/chakra/link'; import type { MediaElementProps } from './utils'; @@ -28,6 +29,12 @@ const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) = }, [ src, handleLoad, onError ]); React.useEffect(() => { + // Disable iframe in private mode to prevent tracking + if (config.app.appProfile === 'private') { + onError?.(); + return; + } + switch (transport) { case 'ipfs': { // Currently we don't support IPFS video loading @@ -40,6 +47,11 @@ const NftHtml = ({ src, transport, onLoad, onError, onClick, ...rest }: Props) = } }, [ loadViaHttp, onError, transport ]); + // Disable iframe in private mode to prevent tracking + if (config.app.appProfile === 'private') { + return null; + } + return ( (null); const rejectCb = React.useRef<((error: Error) => void) | null>(null); @@ -12,6 +14,10 @@ export default function useReCaptcha() { const [ isInitError, setIsInitError ] = React.useState(false); const executeAsync: () => Promise = React.useCallback(async() => { + if (isDisabled) { + return Promise.resolve(null); + } + setIsOpen(true); const tokenPromise = ref.current?.executeAsync() || Promise.reject(new Error('Unable to execute ReCaptcha')); const modalOpenPromise = new Promise((resolve, reject) => { @@ -19,7 +25,7 @@ export default function useReCaptcha() { }); return Promise.race([ tokenPromise, modalOpenPromise ]); - }, [ ref ]); + }, [ ref, isDisabled ]); const handleContainerClick = React.useCallback(() => { setIsOpen(false); @@ -49,7 +55,7 @@ export default function useReCaptcha() { return result; } catch (error) { const statusCode = error instanceof Error ? getErrorCauseStatusCode(error) : getErrorObjStatusCode(error); - if (statusCode === 429) { + if (statusCode === 429 && !isDisabled) { const token = await executeAsync(); if (!token) { @@ -61,7 +67,7 @@ export default function useReCaptcha() { throw error; } - }, [ executeAsync ]); + }, [ executeAsync, isDisabled ]); return React.useMemo(() => ({ ref, From 9fff99a0e025707eb8d2ca3db36fe285beaa1e08 Mon Sep 17 00:00:00 2001 From: isstuev Date: Wed, 19 Nov 2025 15:09:11 +0100 Subject: [PATCH 2/2] for test --- nextjs/middlewares/appProfile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nextjs/middlewares/appProfile.ts b/nextjs/middlewares/appProfile.ts index 412a96beb0..bb548002bf 100644 --- a/nextjs/middlewares/appProfile.ts +++ b/nextjs/middlewares/appProfile.ts @@ -14,7 +14,7 @@ export default function appProfileMiddleware(req: NextRequest, res: NextResponse const profileValue = headerValue || queryValue; if (profileValue === PRIVATE_PROFILE_VALUE) { res.cookies.set(cookiesLib.NAMES.APP_PROFILE, PRIVATE_PROFILE_VALUE, { path: '/' }); - } else { - res.cookies.delete(cookiesLib.NAMES.APP_PROFILE); + // } else { + // res.cookies.delete(cookiesLib.NAMES.APP_PROFILE); } }