From 848320f827cf858b58f3e0b901f7d8bf9667ea41 Mon Sep 17 00:00:00 2001 From: myxmaster Date: Fri, 29 Nov 2024 13:00:11 +0100 Subject: [PATCH 1/4] improved UX and error handling for wallet configuration --- components/TextInput.tsx | 7 +- locales/en.json | 4 + utils/ErrorUtils.test.ts | 12 + utils/ErrorUtils.ts | 2 + views/Settings/WalletConfiguration.tsx | 588 ++++++++++++++++++++----- 5 files changed, 496 insertions(+), 117 deletions(-) diff --git a/components/TextInput.tsx b/components/TextInput.tsx index 1d02e648b..254958daa 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -36,6 +36,7 @@ interface TextInputProps { ref?: React.Ref; error?: boolean; onFocus?: any; + onBlur?: any; onSubmitEditing?: () => void; } @@ -62,7 +63,9 @@ const TextInput = React.forwardRef( toggleUnits, onPressIn, right, - error + error, + onFocus, + onBlur } = props; const defaultStyle = numberOfLines ? { @@ -170,6 +173,8 @@ const TextInput = React.forwardRef( onPressIn={onPressIn} onSubmitEditing={props.onSubmitEditing} ref={ref} + onFocus={onFocus} + onBlur={onBlur} /> {suffix ? ( toggleUnits ? ( diff --git a/locales/en.json b/locales/en.json index 4adc44b82..1f19ed8b3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -128,6 +128,8 @@ "general.experimental": "Experimental", "general.defaultNodeNickname": "My Lightning Node", "general.active": "Active", + "general.pastedInvalidData": "The pasted text contains characters we can't use here. Please check and try again.", + "general.invalidHost": "Invalid host. Please enter a valid host, or host:port.", "restart.title": "Restart required", "restart.msg": "ZEUS has to be restarted before the new configuration is applied.", "restart.msg1": "Would you like to restart now?", @@ -253,6 +255,7 @@ "views.Settings.WalletConfiguration.walletActive": "Wallet Active", "views.Settings.AddEditNode.scanLndconnect": "Scan lndconnect config", "views.Settings.AddEditNode.scanLnc": "Scan LNC QR from Lightning Terminal", + "views.Settings.AddEditNode.wrongLncPairingPhraseLength": "A Lightning Node Connect pairing phrase must contain exactly 10 words. Please check and try again.", "views.Settings.AddEditNode.scanCLightningRest": "Scan c-lightning-REST QR", "views.Settings.AddEditNode.scanBtcpay": "Scan BTCPay config", "views.Settings.AddEditNode.scanLndhub": "Scan LNDHub QR", @@ -1115,6 +1118,7 @@ "error.failureReasonIncorrectPaymentDetails": "Payment failed: Payment details incorrect (unknown payment hash, invalid amount or invalid final CLTV delta).", "error.failureReasonIncorrectPaymentDetailsKeysend": "The receiving node might not accept keysend payments.", "error.failureReasonInsufficientBalance": "Insufficient local balance", + "error.invalidMacaroon": "Invalid macaroon. Please check that you've entered the correct macaroon for this node.", "pos.views.Wallet.PosPane.noOrders": "No orders open at the moment. To send to ZEUS, mark order as 'Other Payment Type' with a note that includes 'Zeus', 'BTC', or 'Bitcoin'", "pos.views.Wallet.PosPane.noOrdersStandalone": "No orders open at the moment", "pos.views.Wallet.PosPane.noOrdersPaid": "No orders have been paid yet", diff --git a/utils/ErrorUtils.test.ts b/utils/ErrorUtils.test.ts index 2a33e5c54..3b88c3e68 100644 --- a/utils/ErrorUtils.test.ts +++ b/utils/ErrorUtils.test.ts @@ -101,6 +101,18 @@ describe('ErrorUtils', () => { ).toEqual( 'Unable to connect to node. Please verify the host and port are correct and the service is running.' ); + expect( + errorToUserFriendly( + Object.assign(new Error(), { + message: + 'Error: {"code":2,"message":"verification failed: signature mismatch after caveat verification","details":[]}', + name: 'test' + }), + false + ) + ).toEqual( + "Invalid macaroon. Please check that you've entered the correct macaroon for this node." + ); }); it('Returns normal error message for unhandled errorContext', () => { diff --git a/utils/ErrorUtils.ts b/utils/ErrorUtils.ts index 557ab6789..bf27f1463 100644 --- a/utils/ErrorUtils.ts +++ b/utils/ErrorUtils.ts @@ -7,6 +7,8 @@ const userFriendlyErrors: any = { 'error.torBootstrap', 'Error: Failed to connect to': 'error.nodeConnectError', 'Error: Unable to resolve host': 'error.nodeConnectError', + 'Error: {"code":2,"message":"verification failed: signature mismatch after caveat verification","details":[]}': + 'error.invalidMacaroon', FAILURE_REASON_TIMEOUT: 'error.failureReasonTimeout', FAILURE_REASON_NO_ROUTE: 'error.failureReasonNoRoute', FAILURE_REASON_ERROR: 'error.failureReasonError', diff --git a/views/Settings/WalletConfiguration.tsx b/views/Settings/WalletConfiguration.tsx index 005ae8d33..7eca39b2e 100644 --- a/views/Settings/WalletConfiguration.tsx +++ b/views/Settings/WalletConfiguration.tsx @@ -16,6 +16,7 @@ import { Route } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { hash, LNC_STORAGE_KEY } from '../../backends/LNC/credentialStore'; +import ModalStore from '../../stores/ModalStore'; import AddressUtils, { CUSTODIAL_LNDHUBS } from '../../utils/AddressUtils'; import ConnectionFormatUtils from '../../utils/ConnectionFormatUtils'; @@ -64,6 +65,7 @@ import { interface WalletConfigurationProps { navigation: StackNavigationProp; SettingsStore: SettingsStore; + ModalStore: ModalStore; route: Route< 'WalletConfiguration', { @@ -129,7 +131,7 @@ const ScanBadge = ({ onPress }: { onPress: () => void }) => ( ); -@inject('SettingsStore') +@inject('SettingsStore', 'ModalStore') @observer export default class WalletConfiguration extends React.Component< WalletConfigurationProps, @@ -327,6 +329,12 @@ export default class WalletConfiguration extends React.Component< this.initFromProps(nextProps); } + componentWillUnmount() { + const { SettingsStore } = this.props; + SettingsStore.createAccountError = ''; + SettingsStore.createAccountSuccess = ''; + } + async initFromProps(props: WalletConfigurationProps) { const { route } = props; @@ -1238,6 +1246,7 @@ export default class WalletConfiguration extends React.Component< } locked={loading} autoCorrect={false} + autoCapitalize="none" /> {implementation === 'spark' && ( @@ -1263,6 +1272,8 @@ export default class WalletConfiguration extends React.Component< }); }} locked={loading} + autoCorrect={false} + autoCapitalize="none" /> )} @@ -1300,6 +1311,7 @@ export default class WalletConfiguration extends React.Component< secureTextEntry={ this.state.hidden } + autoCorrect={false} autoCapitalize="none" style={{ flex: 1, @@ -1333,15 +1345,57 @@ export default class WalletConfiguration extends React.Component< + onChangeText={(text: string) => { + const validHostChars = + /^[a-zA-Z0-9-.:]+$/; + + // Allow backspace/delete operations without validation + if ( + text.length < + (lndhubUrl?.length || 0) + ) { + this.setState({ + lndhubUrl: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (lndhubUrl?.length || 0) + 1 + ) { + const cleanedText = text.replace( + /[^a-zA-Z0-9-.:]/g, + '' + ); + this.setState({ + lndhubUrl: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + if (!validHostChars.test(trimmedText)) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + this.setState({ - lndhubUrl: text.trim(), + lndhubUrl: trimmedText, saved: false - }) - } + }); + }} locked={loading} - autoCorrect={false} /> <> @@ -1382,13 +1436,51 @@ export default class WalletConfiguration extends React.Component< + onChangeText={(text: string) => { + // Allow backspace/delete operations without validation + if ( + text.length < + (username?.length || 0) + ) { + this.setState({ + username: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (username?.length || 0) + 1 + ) { + const cleanedText = + text.trim(); + this.setState({ + username: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + if (/\s/.test(trimmedText)) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + this.setState({ - username: text.trim(), + username: trimmedText, saved: false - }) - } + }); + }} locked={loading} + autoCorrect={false} autoCapitalize="none" /> @@ -1422,6 +1514,7 @@ export default class WalletConfiguration extends React.Component< secureTextEntry={ this.state.hidden } + autoCorrect={false} autoCapitalize="none" style={{ flex: 1, @@ -1457,7 +1550,9 @@ export default class WalletConfiguration extends React.Component< )} )} - {implementation === 'cln-rest' && ( + {(implementation === 'lnd' || + implementation === 'cln-rest' || + implementation === 'c-lightning-REST') && ( <> - this.setState({ - host: text.trim(), - saved: false - }) - } - locked={loading} - /> + onChangeText={(text: string) => { + // Allow backspace/delete operations without validation + if (text.length < (host?.length || 0)) { + this.setState({ + host: text, + saved: false + }); + return; + } - - {localeString( - 'views.Settings.AddEditNode.rune' - )} - - - this.setState({ - rune: text.trim(), - saved: false - }) - } - locked={loading} - /> + // For single character additions + if ( + text.length === + (host?.length || 0) + 1 + ) { + const cleanedText = text.replace( + /[^a-zA-Z0-9-.]/g, + '' + ); - - {localeString( - 'views.Settings.AddEditNode.restPort' - )} - - - this.setState({ - port: text.trim(), - saved: false - }) - } - locked={loading} - /> - - )} + this.setState({ + host: cleanedText, + saved: false + }); + return; + } - {(implementation === 'lnd' || - implementation === 'c-lightning-REST') && ( - <> - - {localeString( - 'views.Settings.AddEditNode.host' - )} - - - this.setState({ - host: text.trim(), - saved: false - }) - } - locked={loading} - /> + // For pasted content + const trimmedText = text.trim(); + if ( + !/^[a-zA-Z0-9-.]+$/.test( + trimmedText + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } - - {localeString( - 'views.Settings.AddEditNode.macaroon' - )} - - this.setState({ - macaroonHex: text.replace( - /\s+/g, - '' - ), + host: trimmedText, saved: false - }) - } + }); + }} locked={loading} /> + {implementation === 'cln-rest' ? ( + <> + + {localeString( + 'views.Settings.AddEditNode.rune' + )} + + { + // Allow backspace/delete operations without validation + if ( + text.length < + (rune?.length || 0) + ) { + this.setState({ + rune: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (rune?.length || 0) + 1 + ) { + const cleanedText = + text.replace( + /[^A-Za-z0-9\-_=]/g, + '' + ); + this.setState({ + rune: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + if ( + !/^[A-Za-z0-9\-_=]+$/.test( + trimmedText + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + + this.setState({ + rune: trimmedText, + saved: false + }); + }} + locked={loading} + /> + + ) : ( + <> + + {localeString( + 'views.Settings.AddEditNode.macaroon' + )} + + { + // Allow backspace/delete operations without validation + if ( + text.length < + (macaroonHex?.length || 0) + ) { + this.setState({ + macaroonHex: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (macaroonHex?.length || 0) + + 1 + ) { + const cleanedText = + text.replace( + /[^0-9a-fA-F]/g, + '' + ); + this.setState({ + macaroonHex: + cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + if ( + !/^[0-9a-fA-F]+$/.test( + trimmedText + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + + this.setState({ + macaroonHex: trimmedText, + saved: false + }); + }} + locked={loading} + /> + + )} + + onChangeText={(text: string) => { + // Allow backspace/delete operations without validation + if ( + text.length < + (customMailboxServer?.length || + 0) + ) { + this.setState({ + customMailboxServer: + text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (customMailboxServer?.length || + 0) + + 1 + ) { + if (text.includes('::')) + return; + + const cleanedText = + text.replace( + /[^a-zA-Z0-9-.:]/g, + '' + ); + this.setState({ + customMailboxServer: + cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + if ( + !/^[a-zA-Z0-9-.:]+$/.test( + trimmedText + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + this.setState({ customMailboxServer: - text.trim(), + trimmedText, saved: false - }) - } + }); + }} + onBlur={() => { + if ( + customMailboxServer && + !/^[a-zA-Z0-9-.]+(:\d+)?$/.test( + customMailboxServer + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.invalidHost' + ) + ); + } + }} locked={loading} /> @@ -1644,13 +1901,86 @@ export default class WalletConfiguration extends React.Component< placeholder={ 'cherry truth mask employ box silver mass bunker fiscal vote' } + autoCapitalize="none" value={pairingPhrase} - onChangeText={(text: string) => + onChangeText={(text: string) => { + // Allow backspace/delete operations without validation + if ( + text.length < + (pairingPhrase?.length || 0) + ) { + this.setState({ + pairingPhrase: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (pairingPhrase?.length || 0) + 1 + ) { + if (text === ' ') return; + if (text.includes(' ')) return; + + const cleanedText = text.replace( + /[^a-zA-Z\s]/g, + '' + ); + this.setState({ + pairingPhrase: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const normalizedPhrase = text + .trim() + .replace(/\s+/g, ' '); + if ( + !/^[a-zA-Z\s]+$/.test( + normalizedPhrase + ) + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'general.pastedInvalidData' + ) + ); + return; + } + this.setState({ - pairingPhrase: text, + pairingPhrase: normalizedPhrase, saved: false - }) - } + }); + }} + onBlur={() => { + const normalizedPhrase = pairingPhrase + ?.trim() + .replace(/\s+/g, ' '); + const wordCount = + normalizedPhrase?.split(' ') + .length || 0; + + if ( + normalizedPhrase && + wordCount !== 10 + ) { + this.props.ModalStore.toggleInfoModal( + localeString( + 'views.Settings.AddEditNode.wrongLncPairingPhraseLength' + ) + ); + } + + this.setState({ + pairingPhrase: normalizedPhrase, + saved: false + }); + }} locked={loading} /> {!!localKey && ( @@ -1773,7 +2103,7 @@ export default class WalletConfiguration extends React.Component< }); } }} - disabled={loading} + disabled={loading || !lndhubUrl} /> )} @@ -1897,11 +2227,37 @@ export default class WalletConfiguration extends React.Component< this.saveWalletConfiguration(); } }} - // disable save button if no creds passed + // disable save button if no host and creds passed disabled={ loading || (implementation === 'lndhub' && - !(username && password)) + !( + lndhubUrl && + username && + password + )) || + (implementation === + 'lightning-node-connect' && + (!pairingPhrase || + pairingPhrase + .trim() + .replace(/\s+/g, ' ') + .split(' ').length !== 10 || + (mailboxServer === + 'custom-defined' && + !/^[a-zA-Z0-9-.]+(:\d+)?$/.test( + customMailboxServer + )))) || + ((implementation === 'lnd' || + implementation === 'cln-rest' || + implementation === + 'c-lightning-REST') && + !( + host && + (implementation === 'cln-rest' + ? rune + : macaroonHex) + )) } /> From 5b6464ba111b1f2743c051880b559995a26e7c7a Mon Sep 17 00:00:00 2001 From: myxmaster Date: Wed, 22 Jan 2025 14:20:53 +0100 Subject: [PATCH 2/4] improve input validation with consistent error handling and tests --- components/TextInput.tsx | 10 +- locales/en.json | 3 - utils/ErrorUtils.test.ts | 3 +- utils/ValidationUtils.test.ts | 197 +++++++++++++ utils/ValidationUtils.ts | 68 +++++ views/Settings/WalletConfiguration.tsx | 382 +++++++++++++++---------- 6 files changed, 504 insertions(+), 159 deletions(-) create mode 100644 utils/ValidationUtils.test.ts create mode 100644 utils/ValidationUtils.ts diff --git a/components/TextInput.tsx b/components/TextInput.tsx index 254958daa..a54421997 100644 --- a/components/TextInput.tsx +++ b/components/TextInput.tsx @@ -19,6 +19,7 @@ interface TextInputProps { numberOfLines?: number; style?: ViewStyle; textInputStyle?: StyleProp; + textColor?: string; placeholderTextColor?: string; locked?: boolean; keyboardType?: KeyboardTypeOptions; @@ -49,6 +50,7 @@ const TextInput = React.forwardRef( numberOfLines, style, textInputStyle, + textColor, placeholderTextColor, locked, keyboardType, @@ -156,9 +158,11 @@ const TextInput = React.forwardRef( style={{ ...StyleSheet.flatten(textInputStyle), ...styles.input, - color: locked - ? themeColor('secondaryText') - : themeColor('text') + color: + textColor || + (locked + ? themeColor('secondaryText') + : themeColor('text')) }} placeholderTextColor={ placeholderTextColor || themeColor('secondaryText') diff --git a/locales/en.json b/locales/en.json index 1f19ed8b3..e413c5279 100644 --- a/locales/en.json +++ b/locales/en.json @@ -128,8 +128,6 @@ "general.experimental": "Experimental", "general.defaultNodeNickname": "My Lightning Node", "general.active": "Active", - "general.pastedInvalidData": "The pasted text contains characters we can't use here. Please check and try again.", - "general.invalidHost": "Invalid host. Please enter a valid host, or host:port.", "restart.title": "Restart required", "restart.msg": "ZEUS has to be restarted before the new configuration is applied.", "restart.msg1": "Would you like to restart now?", @@ -255,7 +253,6 @@ "views.Settings.WalletConfiguration.walletActive": "Wallet Active", "views.Settings.AddEditNode.scanLndconnect": "Scan lndconnect config", "views.Settings.AddEditNode.scanLnc": "Scan LNC QR from Lightning Terminal", - "views.Settings.AddEditNode.wrongLncPairingPhraseLength": "A Lightning Node Connect pairing phrase must contain exactly 10 words. Please check and try again.", "views.Settings.AddEditNode.scanCLightningRest": "Scan c-lightning-REST QR", "views.Settings.AddEditNode.scanBtcpay": "Scan BTCPay config", "views.Settings.AddEditNode.scanLndhub": "Scan LNDHub QR", diff --git a/utils/ErrorUtils.test.ts b/utils/ErrorUtils.test.ts index 3b88c3e68..dedff4d1a 100644 --- a/utils/ErrorUtils.test.ts +++ b/utils/ErrorUtils.test.ts @@ -107,8 +107,7 @@ describe('ErrorUtils', () => { message: 'Error: {"code":2,"message":"verification failed: signature mismatch after caveat verification","details":[]}', name: 'test' - }), - false + }) ) ).toEqual( "Invalid macaroon. Please check that you've entered the correct macaroon for this node." diff --git a/utils/ValidationUtils.test.ts b/utils/ValidationUtils.test.ts new file mode 100644 index 000000000..6b0ef3f3d --- /dev/null +++ b/utils/ValidationUtils.test.ts @@ -0,0 +1,197 @@ +import ValidationUtils from './ValidationUtils'; + +describe('isValidHostAndPort', () => { + it('accepts valid hostname without port', () => { + expect(ValidationUtils.isValidHostAndPort('example.com')).toBe(true); + }); + + it('accepts valid hostname with port', () => { + expect(ValidationUtils.isValidHostAndPort('example.com:8080')).toBe( + true + ); + }); + + it('accepts valid hostname with protocol', () => { + expect(ValidationUtils.isValidHostAndPort('https://example.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostAndPort('http://example.com')).toBe( + true + ); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidHostAndPort('example.com:0')).toBe(false); + expect(ValidationUtils.isValidHostAndPort('example.com:65536')).toBe( + false + ); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHostAndPort('example..com')).toBe(false); + expect(ValidationUtils.isValidHostAndPort('-example.com')).toBe(false); + }); +}); + +describe('isValidHttpsHostAndPort', () => { + it('accepts valid hostname without protocol', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example.com')).toBe( + true + ); + }); + + it('accepts valid hostname with https protocol', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('https://example.com') + ).toBe(true); + }); + + it('accepts valid hostname with port', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('example.com:8080') + ).toBe(true); + expect( + ValidationUtils.isValidHttpsHostAndPort('https://example.com:8080') + ).toBe(true); + }); + + it('rejects hostname with http protocol', () => { + expect( + ValidationUtils.isValidHttpsHostAndPort('http://example.com') + ).toBe(false); + expect( + ValidationUtils.isValidHttpsHostAndPort('http://example.com:8080') + ).toBe(false); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example.com:0')).toBe( + false + ); + expect( + ValidationUtils.isValidHttpsHostAndPort('example.com:65536') + ).toBe(false); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHttpsHostAndPort('example..com')).toBe( + false + ); + expect(ValidationUtils.isValidHttpsHostAndPort('-example.com')).toBe( + false + ); + }); +}); + +describe('isValidHostname', () => { + it('accepts valid hostnames', () => { + expect(ValidationUtils.isValidHostname('example.com')).toBe(true); + expect(ValidationUtils.isValidHostname('sub.example.com')).toBe(true); + expect(ValidationUtils.isValidHostname('example-domain.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostname('localhost')).toBe(true); + expect(ValidationUtils.isValidHostname('10.0.2.2')).toBe(true); + }); + + it('accepts hostnames with protocol', () => { + expect(ValidationUtils.isValidHostname('http://example.com')).toBe( + true + ); + expect(ValidationUtils.isValidHostname('https://example.com')).toBe( + true + ); + }); + + it('rejects invalid hostnames', () => { + expect(ValidationUtils.isValidHostname('example..com')).toBe(false); + expect(ValidationUtils.isValidHostname('.example.com')).toBe(false); + expect(ValidationUtils.isValidHostname('example.com:')).toBe(false); + }); +}); + +describe('isValidPort', () => { + it('accepts valid port numbers', () => { + expect(ValidationUtils.isValidPort('80')).toBe(true); + expect(ValidationUtils.isValidPort('8080')).toBe(true); + expect(ValidationUtils.isValidPort('65535')).toBe(true); + }); + + it('rejects invalid port numbers', () => { + expect(ValidationUtils.isValidPort('0')).toBe(false); + expect(ValidationUtils.isValidPort('65536')).toBe(false); + expect(ValidationUtils.isValidPort('-1')).toBe(false); + }); +}); + +describe('hasValidRuneChars', () => { + it('accepts valid rune characters', () => { + expect(ValidationUtils.hasValidRuneChars('abcDEF123-_=')).toBe(true); + expect(ValidationUtils.hasValidRuneChars('ABC123')).toBe(true); + }); + + it('rejects invalid rune characters', () => { + expect(ValidationUtils.hasValidRuneChars('abc!')).toBe(false); + expect(ValidationUtils.hasValidRuneChars('abc@')).toBe(false); + expect(ValidationUtils.hasValidRuneChars('abc space')).toBe(false); + }); +}); + +describe('hasValidMacaroonChars', () => { + it('accepts valid macaroon hex characters', () => { + expect(ValidationUtils.hasValidMacaroonChars('0123456789abcdef')).toBe( + true + ); + expect(ValidationUtils.hasValidMacaroonChars('ABCDEF')).toBe(true); + }); + + it('rejects invalid macaroon hex characters', () => { + expect(ValidationUtils.hasValidMacaroonChars('0123g')).toBe(false); + expect(ValidationUtils.hasValidMacaroonChars('abcd!')).toBe(false); + expect(ValidationUtils.hasValidMacaroonChars('0123 4567')).toBe(false); + }); +}); + +describe('hasValidPairingPhraseCharsAndWordcount', () => { + it('accepts valid 10-word pairing phrase', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal vote' + ) + ).toBe(true); + }); + + it('accepts phrase with extra spaces and normalizes', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + ' cherry truth mask employ box silver mass bunker fiscal vote ' + ) + ).toBe(true); + }); + + it('rejects phrases with wrong word count', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'less than ten words' + ) + ).toBe(false); + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'more than ten words truth mask employ box silver mass bunker' + ) + ).toBe(false); + }); + + it('rejects phrases with invalid characters', () => { + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal 123' + ) + ).toBe(false); + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ box silver mass bunker fiscal @' + ) + ).toBe(false); + }); +}); diff --git a/utils/ValidationUtils.ts b/utils/ValidationUtils.ts new file mode 100644 index 000000000..925251b5e --- /dev/null +++ b/utils/ValidationUtils.ts @@ -0,0 +1,68 @@ +const isValidHostAndPort = (input: string): boolean => { + const urlPattern = + /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; + const portPattern = /:\d+$/; + + if (!urlPattern.test(input)) return false; + + const portMatch = input.match(portPattern); + if (portMatch) { + const port = parseInt(portMatch[0].substring(1)); + if (port < 1 || port > 65535) return false; + } + + return true; +}; + +const isValidHttpsHostAndPort = (input: string): boolean => { + const urlPattern = + /^(https:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; + const portPattern = /:\d+$/; + + if (!urlPattern.test(input)) return false; + + const portMatch = input.match(portPattern); + if (portMatch) { + const port = parseInt(portMatch[0].substring(1)); + if (port < 1 || port > 65535) return false; + } + + return true; +}; + +const isValidHostname = (hostname: string): boolean => { + const urlPattern = + /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; + return urlPattern.test(hostname); +}; + +const isValidPort = (port: string): boolean => { + const portNum = parseInt(port); + return portNum >= 1 && portNum <= 65535; +}; + +const hasValidRuneChars = (rune: string): boolean => { + return /^[A-Za-z0-9\-_=]+$/.test(rune); +}; + +const hasValidMacaroonChars = (macaroon: string): boolean => { + return /^[0-9a-fA-F]+$/.test(macaroon); +}; + +const hasValidPairingPhraseCharsAndWordcount = (phrase: string): boolean => { + const normalizedPhrase = phrase.trim().replace(/\s+/g, ' '); + if (!/^[a-zA-Z\s]+$/.test(normalizedPhrase)) return false; + return normalizedPhrase.split(' ').length === 10; +}; + +const ValidationUtils = { + isValidHostAndPort, + isValidHttpsHostAndPort, + isValidHostname, + isValidPort, + hasValidRuneChars, + hasValidMacaroonChars, + hasValidPairingPhraseCharsAndWordcount +}; + +export default ValidationUtils; diff --git a/views/Settings/WalletConfiguration.tsx b/views/Settings/WalletConfiguration.tsx index 7eca39b2e..a8e4ee4c3 100644 --- a/views/Settings/WalletConfiguration.tsx +++ b/views/Settings/WalletConfiguration.tsx @@ -23,6 +23,7 @@ import ConnectionFormatUtils from '../../utils/ConnectionFormatUtils'; import { localeString } from '../../utils/LocaleUtils'; import BackendUtils from '../../utils/BackendUtils'; import { themeColor } from '../../utils/ThemeUtils'; +import ValidationUtils from '../../utils/ValidationUtils'; import Storage from '../../storage'; @@ -123,6 +124,14 @@ interface WalletConfigurationState { channelBackupsBase64: string; creatingWallet: boolean; errorCreatingWallet: boolean; + lndhubUrlError: boolean; + usernameError: boolean; + hostError: boolean; + runeError: boolean; + macaroonHexError: boolean; + portError: boolean; + customMailboxServerError: boolean; + pairingPhraseError: boolean; } const ScanBadge = ({ onPress }: { onPress: () => void }) => ( @@ -179,7 +188,15 @@ export default class WalletConfiguration extends React.Component< recoveryCipherSeed: '', channelBackupsBase64: '', creatingWallet: false, - errorCreatingWallet: false + errorCreatingWallet: false, + lndhubUrlError: false, + usernameError: false, + hostError: false, + runeError: false, + macaroonHexError: false, + portError: false, + customMailboxServerError: false, + pairingPhraseError: false }; scrollViewRef = React.createRef(); @@ -728,7 +745,15 @@ export default class WalletConfiguration extends React.Component< recoveryCipherSeed, channelBackupsBase64, creatingWallet, - errorCreatingWallet + errorCreatingWallet, + lndhubUrlError, + usernameError, + hostError, + runeError, + macaroonHexError, + portError, + customMailboxServerError, + pairingPhraseError } = this.state; const { loading, @@ -1153,18 +1178,16 @@ export default class WalletConfiguration extends React.Component< {!adminMacaroon && implementation === 'embedded-lnd' && ( - {!adminMacaroon && ( - { - this.setState({ - embeddedLndNetwork: value - }); - }} - values={EMBEDDED_NODE_NETWORK_KEYS} - /> - )} + { + this.setState({ + embeddedLndNetwork: value + }); + }} + values={EMBEDDED_NODE_NETWORK_KEYS} + /> {false && ( <> { - const validHostChars = - /^[a-zA-Z0-9-.:]+$/; + this.setState({ + lndhubUrlError: false + }); // Allow backspace/delete operations without validation if ( @@ -1369,7 +1398,7 @@ export default class WalletConfiguration extends React.Component< (lndhubUrl?.length || 0) + 1 ) { const cleanedText = text.replace( - /[^a-zA-Z0-9-.:]/g, + /[^a-zA-Z0-9-.:/]/g, '' ); this.setState({ @@ -1381,20 +1410,25 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if (!validHostChars.test(trimmedText)) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ lndhubUrl: trimmedText, + lndhubUrlError: + !ValidationUtils.isValidHostAndPort( + trimmedText + ), saved: false }); }} + onBlur={() => { + if (lndhubUrl) { + this.setState({ + lndhubUrlError: + !ValidationUtils.isValidHostAndPort( + lndhubUrl + ) + }); + } + }} locked={loading} /> @@ -1435,8 +1469,22 @@ export default class WalletConfiguration extends React.Component< + this.setState({ + usernameError: false + }) + } onChangeText={(text: string) => { + this.setState({ + usernameError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1465,17 +1513,11 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if (/\s/.test(trimmedText)) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ username: trimmedText, + usernameError: /\s/.test( + trimmedText + ), saved: false }); }} @@ -1565,10 +1607,17 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ hostError: false }); + // Allow backspace/delete operations without validation if (text.length < (host?.length || 0)) { this.setState({ @@ -1584,10 +1633,9 @@ export default class WalletConfiguration extends React.Component< (host?.length || 0) + 1 ) { const cleanedText = text.replace( - /[^a-zA-Z0-9-.]/g, + /[^a-zA-Z0-9-./:]/g, '' ); - this.setState({ host: cleanedText, saved: false @@ -1597,24 +1645,25 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[a-zA-Z0-9-.]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ host: trimmedText, + hostError: + !ValidationUtils.isValidHostname( + trimmedText + ), saved: false }); }} + onBlur={() => { + if (host) { + this.setState({ + hostError: + !ValidationUtils.isValidHostname( + host + ) + }); + } + }} locked={loading} /> @@ -1633,10 +1682,19 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ + runeError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1668,21 +1726,12 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[A-Za-z0-9\-_=]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ rune: trimmedText, + runeError: + !ValidationUtils.hasValidRuneChars( + trimmedText + ), saved: false }); }} @@ -1704,10 +1753,19 @@ export default class WalletConfiguration extends React.Component< { + this.setState({ + macaroonHexError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1741,21 +1799,12 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[0-9a-fA-F]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ macaroonHex: trimmedText, + macaroonHexError: + !ValidationUtils.hasValidMacaroonChars( + trimmedText + ), saved: false }); }} @@ -1776,13 +1825,60 @@ export default class WalletConfiguration extends React.Component< + onChangeText={(text: string) => { + this.setState({ portError: false }); + + // Allow backspace/delete operations without validation + if (text.length < (port?.length || 0)) { + this.setState({ + port: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (port?.length || 0) + 1 + ) { + const cleanedText = text + .replace(/[^0-9]/g, '') + .replace(/^0+/, ''); + this.setState({ + port: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); this.setState({ - port: text.trim(), + port: trimmedText, + portError: + !ValidationUtils.isValidPort( + trimmedText + ), saved: false - }) - } + }); + }} + onBlur={() => { + if (port) { + this.setState({ + portError: + !ValidationUtils.isValidPort( + port + ) + }); + } + }} locked={loading} /> @@ -1808,10 +1904,20 @@ export default class WalletConfiguration extends React.Component< placeholder={ 'my-custom.lnc.server:443' } + textColor={ + customMailboxServerError + ? themeColor('error') + : themeColor('text') + } autoCorrect={false} autoCapitalize="none" value={customMailboxServer} onChangeText={(text: string) => { + this.setState({ + customMailboxServerError: + false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1838,7 +1944,7 @@ export default class WalletConfiguration extends React.Component< const cleanedText = text.replace( - /[^a-zA-Z0-9-.:]/g, + /[^a-zA-Z0-9-./:]/g, '' ); this.setState({ @@ -1851,37 +1957,24 @@ export default class WalletConfiguration extends React.Component< // For pasted content const trimmedText = text.trim(); - if ( - !/^[a-zA-Z0-9-.:]+$/.test( - trimmedText - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ customMailboxServer: trimmedText, + customMailboxServerError: + !ValidationUtils.isValidHttpsHostAndPort( + trimmedText + ), saved: false }); }} onBlur={() => { - if ( - customMailboxServer && - !/^[a-zA-Z0-9-.]+(:\d+)?$/.test( - customMailboxServer - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.invalidHost' - ) - ); + if (customMailboxServer) { + this.setState({ + customMailboxServerError: + !ValidationUtils.isValidHttpsHostAndPort( + customMailboxServer + ) + }); } }} locked={loading} @@ -1901,9 +1994,18 @@ export default class WalletConfiguration extends React.Component< placeholder={ 'cherry truth mask employ box silver mass bunker fiscal vote' } + textColor={ + pairingPhraseError + ? themeColor('error') + : themeColor('text') + } autoCapitalize="none" value={pairingPhrase} onChangeText={(text: string) => { + this.setState({ + pairingPhraseError: false + }); + // Allow backspace/delete operations without validation if ( text.length < @@ -1939,21 +2041,12 @@ export default class WalletConfiguration extends React.Component< const normalizedPhrase = text .trim() .replace(/\s+/g, ' '); - if ( - !/^[a-zA-Z\s]+$/.test( - normalizedPhrase - ) - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'general.pastedInvalidData' - ) - ); - return; - } - this.setState({ pairingPhrase: normalizedPhrase, + pairingPhraseError: + !ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + normalizedPhrase + ), saved: false }); }} @@ -1961,28 +2054,18 @@ export default class WalletConfiguration extends React.Component< const normalizedPhrase = pairingPhrase ?.trim() .replace(/\s+/g, ' '); - const wordCount = - normalizedPhrase?.split(' ') - .length || 0; - - if ( - normalizedPhrase && - wordCount !== 10 - ) { - this.props.ModalStore.toggleInfoModal( - localeString( - 'views.Settings.AddEditNode.wrongLncPairingPhraseLength' - ) - ); - } - this.setState({ pairingPhrase: normalizedPhrase, + pairingPhraseError: + !ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + normalizedPhrase + ), saved: false }); }} locked={loading} /> + {!!localKey && ( <> From cf020814a5336f59607874ef9b18413003039a16 Mon Sep 17 00:00:00 2001 From: myxmaster Date: Wed, 22 Jan 2025 14:58:09 +0100 Subject: [PATCH 3/4] improve disable logic for save button, remove unused ModalStore --- views/Settings/WalletConfiguration.tsx | 32 +++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/views/Settings/WalletConfiguration.tsx b/views/Settings/WalletConfiguration.tsx index a8e4ee4c3..e281d3674 100644 --- a/views/Settings/WalletConfiguration.tsx +++ b/views/Settings/WalletConfiguration.tsx @@ -16,7 +16,6 @@ import { Route } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { hash, LNC_STORAGE_KEY } from '../../backends/LNC/credentialStore'; -import ModalStore from '../../stores/ModalStore'; import AddressUtils, { CUSTODIAL_LNDHUBS } from '../../utils/AddressUtils'; import ConnectionFormatUtils from '../../utils/ConnectionFormatUtils'; @@ -66,7 +65,6 @@ import { interface WalletConfigurationProps { navigation: StackNavigationProp; SettingsStore: SettingsStore; - ModalStore: ModalStore; route: Route< 'WalletConfiguration', { @@ -140,7 +138,7 @@ const ScanBadge = ({ onPress }: { onPress: () => void }) => ( ); -@inject('SettingsStore', 'ModalStore') +@inject('SettingsStore') @observer export default class WalletConfiguration extends React.Component< WalletConfigurationProps, @@ -2314,12 +2312,40 @@ export default class WalletConfiguration extends React.Component< disabled={ loading || hostError || + (host && + !ValidationUtils.isValidHostname( + host + )) || portError || + (port && + !ValidationUtils.isValidPort( + port + )) || macaroonHexError || + (macaroonHex && + !ValidationUtils.hasValidMacaroonChars( + macaroonHex + )) || runeError || + (rune && + !ValidationUtils.hasValidRuneChars( + rune + )) || lndhubUrlError || + (lndhubUrl && + !ValidationUtils.isValidHostAndPort( + lndhubUrl + )) || customMailboxServerError || + (customMailboxServer && + !ValidationUtils.isValidHttpsHostAndPort( + customMailboxServer + )) || pairingPhraseError || + (pairingPhrase && + !ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + pairingPhrase + )) || // Required input check // Port is optional, it will fallback to 80 or 443 (implementation === 'lndhub' && From 64950ea774904a622eababf1b298c4ddf175f179 Mon Sep 17 00:00:00 2001 From: myxmaster Date: Wed, 29 Jan 2025 20:14:05 +0100 Subject: [PATCH 4/4] rename host to server address, move port above macaroon/rune, allow paths, general improved validation --- locales/en.json | 2 +- utils/ValidationUtils.test.ts | 266 ++++++++++++++++++------- utils/ValidationUtils.ts | 82 +++++--- views/Settings/WalletConfiguration.tsx | 209 ++++++++++--------- 4 files changed, 370 insertions(+), 189 deletions(-) diff --git a/locales/en.json b/locales/en.json index e413c5279..1ff866a9c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -241,7 +241,7 @@ "views.Settings.AddEditNode.showAccountQR": "Show account QR", "views.Settings.AddEditNode.hideAccountQR": "Hide account QR", "views.Settings.AddEditNode.nickname": "Nickname (optional)", - "views.Settings.AddEditNode.host": "Host", + "views.Settings.AddEditNode.serverAddress": "Server address", "views.Settings.AddEditNode.accessKey": "Access Key", "views.Settings.AddEditNode.restPort": "REST Port", "views.Settings.AddEditNode.macaroon": "Macaroon (Hex format)", diff --git a/utils/ValidationUtils.test.ts b/utils/ValidationUtils.test.ts index 6b0ef3f3d..f9f35deee 100644 --- a/utils/ValidationUtils.test.ts +++ b/utils/ValidationUtils.test.ts @@ -1,112 +1,236 @@ import ValidationUtils from './ValidationUtils'; -describe('isValidHostAndPort', () => { - it('accepts valid hostname without port', () => { - expect(ValidationUtils.isValidHostAndPort('example.com')).toBe(true); - }); - - it('accepts valid hostname with port', () => { - expect(ValidationUtils.isValidHostAndPort('example.com:8080')).toBe( +describe('isValidServerAddress', () => { + it('accepts valid server addresses without port', () => { + expect(ValidationUtils.isValidServerAddress('example.com')).toBe(true); + expect( + ValidationUtils.isValidServerAddress('subdomain.example.com') + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('sub-domain.example.space') + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress( + 'some.more.subs.example-domain.de' + ) + ).toBe(true); + expect(ValidationUtils.isValidServerAddress('10.0.2.2')).toBe(true); + expect(ValidationUtils.isValidServerAddress('[2001:db8::1]')).toBe( true ); + expect(ValidationUtils.isValidServerAddress('localhost')).toBe(true); + }); + + it('accepts valid server addresses with port when allowed', () => { + expect( + ValidationUtils.isValidServerAddress('example.com:443', { + allowPort: true + }) + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('10.0.2.2:4001', { + allowPort: true + }) + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('[2001:db8::1]:8080', { + allowPort: true + }) + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('localhost:21021', { + allowPort: true + }) + ).toBe(true); }); - it('accepts valid hostname with protocol', () => { - expect(ValidationUtils.isValidHostAndPort('https://example.com')).toBe( + it('accepts valid server addresses with protocol', () => { + expect( + ValidationUtils.isValidServerAddress('https://example.com') + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('HTTPS://example.com') + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('http://subdomain.example.com') + ).toBe(true); + expect(ValidationUtils.isValidServerAddress('https://10.0.2.2')).toBe( true ); - expect(ValidationUtils.isValidHostAndPort('http://example.com')).toBe( + expect(ValidationUtils.isValidServerAddress('http://10.0.2.2')).toBe( true ); - }); - - it('rejects invalid port numbers', () => { - expect(ValidationUtils.isValidHostAndPort('example.com:0')).toBe(false); - expect(ValidationUtils.isValidHostAndPort('example.com:65536')).toBe( - false + expect( + ValidationUtils.isValidServerAddress('https://[2001:db8::1]') + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('http://[2001:db8::1]') + ).toBe(true); + expect(ValidationUtils.isValidServerAddress('https://localhost')).toBe( + true ); - }); - - it('rejects invalid hostnames', () => { - expect(ValidationUtils.isValidHostAndPort('example..com')).toBe(false); - expect(ValidationUtils.isValidHostAndPort('-example.com')).toBe(false); - }); -}); - -describe('isValidHttpsHostAndPort', () => { - it('accepts valid hostname without protocol', () => { - expect(ValidationUtils.isValidHttpsHostAndPort('example.com')).toBe( + expect(ValidationUtils.isValidServerAddress('http://localhost')).toBe( true ); }); - it('accepts valid hostname with https protocol', () => { + it('accepts valid paths', () => { + expect(ValidationUtils.isValidServerAddress('example.com/')).toBe(true); + expect(ValidationUtils.isValidServerAddress('example.com/path')).toBe( + true + ); + expect(ValidationUtils.isValidServerAddress('example.com/path/')).toBe( + true + ); expect( - ValidationUtils.isValidHttpsHostAndPort('https://example.com') + ValidationUtils.isValidServerAddress('10.0.2.2/way/more/paths') ).toBe(true); - }); - - it('accepts valid hostname with port', () => { expect( - ValidationUtils.isValidHttpsHostAndPort('example.com:8080') + ValidationUtils.isValidServerAddress('10.0.2.2/way/more/paths/') ).toBe(true); expect( - ValidationUtils.isValidHttpsHostAndPort('https://example.com:8080') + ValidationUtils.isValidServerAddress( + '[2001:db8::1]/path-with_crazy!chars' + ) + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress( + '[2001:db8::1]/path-with-numbers2' + ) ).toBe(true); }); - it('rejects hostname with http protocol', () => { + it('rejects invalid port numbers', () => { + expect( + ValidationUtils.isValidServerAddress('example.com:0', { + allowPort: true + }) + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('example.com:65536', { + allowPort: true + }) + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('example.com:-80', { + allowPort: true + }) + ).toBe(false); expect( - ValidationUtils.isValidHttpsHostAndPort('http://example.com') + ValidationUtils.isValidServerAddress('example.com: 80', { + allowPort: true + }) ).toBe(false); expect( - ValidationUtils.isValidHttpsHostAndPort('http://example.com:8080') + ValidationUtils.isValidServerAddress('example.com:80.80', { + allowPort: true + }) + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('example.com:abc', { + allowPort: true + }) ).toBe(false); }); - it('rejects invalid port numbers', () => { - expect(ValidationUtils.isValidHttpsHostAndPort('example.com:0')).toBe( + it('rejects invalid server addresses', () => { + expect(ValidationUtils.isValidServerAddress('http:/example.com')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('http//example.com')).toBe( false ); expect( - ValidationUtils.isValidHttpsHostAndPort('example.com:65536') + ValidationUtils.isValidServerAddress('http:///example.com') ).toBe(false); - }); - - it('rejects invalid hostnames', () => { - expect(ValidationUtils.isValidHttpsHostAndPort('example..com')).toBe( + expect(ValidationUtils.isValidServerAddress('ex!ample.com')).toBe( false ); - expect(ValidationUtils.isValidHttpsHostAndPort('-example.com')).toBe( + expect(ValidationUtils.isValidServerAddress('ex_ample.com')).toBe( false ); - }); -}); - -describe('isValidHostname', () => { - it('accepts valid hostnames', () => { - expect(ValidationUtils.isValidHostname('example.com')).toBe(true); - expect(ValidationUtils.isValidHostname('sub.example.com')).toBe(true); - expect(ValidationUtils.isValidHostname('example-domain.com')).toBe( - true + expect(ValidationUtils.isValidServerAddress('example .com')).toBe( + false ); - expect(ValidationUtils.isValidHostname('localhost')).toBe(true); - expect(ValidationUtils.isValidHostname('10.0.2.2')).toBe(true); - }); - - it('accepts hostnames with protocol', () => { - expect(ValidationUtils.isValidHostname('http://example.com')).toBe( - true + expect(ValidationUtils.isValidServerAddress('example. com')).toBe( + false ); - expect(ValidationUtils.isValidHostname('https://example.com')).toBe( - true + expect(ValidationUtils.isValidServerAddress(' example.com')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('example.com ')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('exam ple.com')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('example..com')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('-example.com')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('example.com2')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('example.c')).toBe(false); + expect(ValidationUtils.isValidServerAddress('example.123')).toBe(false); + expect(ValidationUtils.isValidServerAddress('example.com//no')).toBe( + false + ); + expect( + ValidationUtils.isValidServerAddress('example.com/not/ok//') + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('example.com/nobody/does:this') + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress( + 'example.com/nobody/does/this/either:8080' + ) + ).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.0.2.2')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.2')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.a.2')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.2a.2')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10,0.0.2')).toBe(false); + expect(ValidationUtils.isValidServerAddress('256.0.0.1')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.256.0.1')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.256.1')).toBe(false); + expect(ValidationUtils.isValidServerAddress('10.0.0.256')).toBe(false); + expect(ValidationUtils.isValidServerAddress('2001:db8::1')).toBe(false); + expect(ValidationUtils.isValidServerAddress('[2001:db8::1')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('2001:db8::1]')).toBe( + false + ); + expect(ValidationUtils.isValidServerAddress('[2001:db8:::1]')).toBe( + false ); }); - it('rejects invalid hostnames', () => { - expect(ValidationUtils.isValidHostname('example..com')).toBe(false); - expect(ValidationUtils.isValidHostname('.example.com')).toBe(false); - expect(ValidationUtils.isValidHostname('example.com:')).toBe(false); + it('rejects HTTP when HTTPS is required', () => { + expect( + ValidationUtils.isValidServerAddress('http://example.com', { + requireHttps: true + }) + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('HTTP://example.com', { + requireHttps: true + }) + ).toBe(false); + expect( + ValidationUtils.isValidServerAddress('https://example.com', { + requireHttps: true + }) + ).toBe(true); + expect( + ValidationUtils.isValidServerAddress('example.com', { + requireHttps: true + }) + ).toBe(true); }); }); @@ -121,6 +245,7 @@ describe('isValidPort', () => { expect(ValidationUtils.isValidPort('0')).toBe(false); expect(ValidationUtils.isValidPort('65536')).toBe(false); expect(ValidationUtils.isValidPort('-1')).toBe(false); + expect(ValidationUtils.isValidPort('a')).toBe(false); }); }); @@ -133,7 +258,7 @@ describe('hasValidRuneChars', () => { it('rejects invalid rune characters', () => { expect(ValidationUtils.hasValidRuneChars('abc!')).toBe(false); expect(ValidationUtils.hasValidRuneChars('abc@')).toBe(false); - expect(ValidationUtils.hasValidRuneChars('abc space')).toBe(false); + expect(ValidationUtils.hasValidRuneChars('abc def')).toBe(false); }); }); @@ -193,5 +318,10 @@ describe('hasValidPairingPhraseCharsAndWordcount', () => { 'cherry truth mask employ box silver mass bunker fiscal @' ) ).toBe(false); + expect( + ValidationUtils.hasValidPairingPhraseCharsAndWordcount( + 'cherry truth mask employ bo!x silver mass bunker fiscal' + ) + ).toBe(false); }); }); diff --git a/utils/ValidationUtils.ts b/utils/ValidationUtils.ts index 925251b5e..39da1fc20 100644 --- a/utils/ValidationUtils.ts +++ b/utils/ValidationUtils.ts @@ -1,39 +1,63 @@ -const isValidHostAndPort = (input: string): boolean => { - const urlPattern = - /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; - const portPattern = /:\d+$/; +// we incorporate domains (incl. optional subdomains), IPv4, IPv6 and local hostnames ('localhost' etc.) +const HOST_REGEX = + /^(([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b|\[(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))\]|[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)$/; +// in paths we have more allowed chars +const PATH_REGEX = /^\/([a-zA-Z0-9\-._~!$&'()*+,;=]+\/?)*$/; +const PORT_REGEX = /^:\d+$/; - if (!urlPattern.test(input)) return false; +interface ValidationOptions { + requireHttps?: boolean; + allowPort?: boolean; +} - const portMatch = input.match(portPattern); - if (portMatch) { - const port = parseInt(portMatch[0].substring(1)); - if (port < 1 || port > 65535) return false; +// Convert protocol (if present) to lowercase while preserving the rest of the URL +// This handles case-insensitive protocol matching (HTTPS:// -> https://) +const preprocessInput = (input: string): string => { + const protocolMatch = input.match(/^https?:\/\//i); + if (protocolMatch) { + return input.replace(protocolMatch[0], protocolMatch[0].toLowerCase()); } - - return true; + return input; }; -const isValidHttpsHostAndPort = (input: string): boolean => { - const urlPattern = - /^(https:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])(:\d+)?$/; - const portPattern = /:\d+$/; +const isValidServerAddress = ( + input: string, + options: ValidationOptions = {} +): boolean => { + try { + const urlRegex = options.allowPort + ? /^(https?:\/\/)?([^/:]+|\[?[a-fA-F0-9:]+\]?)(:\d+)?(\/.*)?$/ + : /^(https?:\/\/)?([^/:]+|\[?[a-fA-F0-9:]+\]?)(\/.*)?$/; - if (!urlPattern.test(input)) return false; + input = preprocessInput(input); + const match = input.match(urlRegex); + if (!match) return false; - const portMatch = input.match(portPattern); - if (portMatch) { - const port = parseInt(portMatch[0].substring(1)); - if (port < 1 || port > 65535) return false; - } + const protocol = match[1]; + const host = match[2]; + const port = options.allowPort ? match[3] : null; + const path = options.allowPort ? match[4] : match[3]; - return true; -}; + if (options.requireHttps) { + if (protocol && protocol !== 'https://') return false; + } else if (protocol && !/^https?:\/\//.test(protocol)) return false; + + if (!HOST_REGEX.test(host)) return false; -const isValidHostname = (hostname: string): boolean => { - const urlPattern = - /^(https?:\/\/)?(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/; - return urlPattern.test(hostname); + if ( + port && + (!PORT_REGEX.test(port) || + parseInt(port.slice(1)) < 1 || + parseInt(port.slice(1)) > 65535) + ) + return false; + + if (path && !PATH_REGEX.test(path)) return false; + + return true; + } catch (error) { + return false; + } }; const isValidPort = (port: string): boolean => { @@ -56,9 +80,7 @@ const hasValidPairingPhraseCharsAndWordcount = (phrase: string): boolean => { }; const ValidationUtils = { - isValidHostAndPort, - isValidHttpsHostAndPort, - isValidHostname, + isValidServerAddress, isValidPort, hasValidRuneChars, hasValidMacaroonChars, diff --git a/views/Settings/WalletConfiguration.tsx b/views/Settings/WalletConfiguration.tsx index e281d3674..3328ec870 100644 --- a/views/Settings/WalletConfiguration.tsx +++ b/views/Settings/WalletConfiguration.tsx @@ -706,6 +706,8 @@ export default class WalletConfiguration extends React.Component< }; render() { + const SERVER_ADDRESS_CHARS = "a-zA-Z0-9-._~!$&'()*+,;="; + const { navigation, SettingsStore } = this.props; const { node, @@ -1253,7 +1255,7 @@ export default class WalletConfiguration extends React.Component< }} > {localeString( - 'views.Settings.AddEditNode.host' + 'views.Settings.AddEditNode.serverAddress' )} {localeString( - 'views.Settings.AddEditNode.host' + 'views.Settings.AddEditNode.serverAddress' )} {localeString( - 'views.Settings.AddEditNode.host' + 'views.Settings.AddEditNode.serverAddress' )} + + {localeString( + 'views.Settings.AddEditNode.restPort' + )} + + { + this.setState({ portError: false }); + + // Allow backspace/delete operations without validation + if (text.length < (port?.length || 0)) { + this.setState({ + port: text, + saved: false + }); + return; + } + + // For single character additions + if ( + text.length === + (port?.length || 0) + 1 + ) { + const cleanedText = text + .replace(/[^0-9]/g, '') + .replace(/^0+/, ''); + this.setState({ + port: cleanedText, + saved: false + }); + return; + } + + // For pasted content + const trimmedText = text.trim(); + this.setState({ + port: trimmedText, + portError: + !ValidationUtils.isValidPort( + trimmedText + ), + saved: false + }); + }} + onBlur={() => { + if (port) { + this.setState({ + portError: + !ValidationUtils.isValidPort( + port + ) + }); + } + }} + locked={loading} + /> + {implementation === 'cln-rest' ? ( <> )} - - - {localeString( - 'views.Settings.AddEditNode.restPort' - )} - - { - this.setState({ portError: false }); - - // Allow backspace/delete operations without validation - if (text.length < (port?.length || 0)) { - this.setState({ - port: text, - saved: false - }); - return; - } - - // For single character additions - if ( - text.length === - (port?.length || 0) + 1 - ) { - const cleanedText = text - .replace(/[^0-9]/g, '') - .replace(/^0+/, ''); - this.setState({ - port: cleanedText, - saved: false - }); - return; - } - - // For pasted content - const trimmedText = text.trim(); - this.setState({ - port: trimmedText, - portError: - !ValidationUtils.isValidPort( - trimmedText - ), - saved: false - }); - }} - onBlur={() => { - if (port) { - this.setState({ - portError: - !ValidationUtils.isValidPort( - port - ) - }); - } - }} - locked={loading} - /> )} @@ -1942,7 +1952,10 @@ export default class WalletConfiguration extends React.Component< const cleanedText = text.replace( - /[^a-zA-Z0-9-./:]/g, + new RegExp( + `[^${SERVER_ADDRESS_CHARS}:/]`, + 'g' + ), '' ); this.setState({ @@ -1959,8 +1972,13 @@ export default class WalletConfiguration extends React.Component< customMailboxServer: trimmedText, customMailboxServerError: - !ValidationUtils.isValidHttpsHostAndPort( - trimmedText + !ValidationUtils.isValidServerAddress( + trimmedText, + { + allowPort: true, + requireHttps: + true + } ), saved: false }); @@ -1969,8 +1987,14 @@ export default class WalletConfiguration extends React.Component< if (customMailboxServer) { this.setState({ customMailboxServerError: - !ValidationUtils.isValidHttpsHostAndPort( - customMailboxServer + !ValidationUtils.isValidServerAddress( + customMailboxServer, + { + allowPort: + true, + requireHttps: + true + } ) }); } @@ -2313,7 +2337,7 @@ export default class WalletConfiguration extends React.Component< loading || hostError || (host && - !ValidationUtils.isValidHostname( + !ValidationUtils.isValidServerAddress( host )) || portError || @@ -2333,13 +2357,18 @@ export default class WalletConfiguration extends React.Component< )) || lndhubUrlError || (lndhubUrl && - !ValidationUtils.isValidHostAndPort( - lndhubUrl + !ValidationUtils.isValidServerAddress( + lndhubUrl, + { allowPort: true } )) || customMailboxServerError || (customMailboxServer && - !ValidationUtils.isValidHttpsHostAndPort( - customMailboxServer + !ValidationUtils.isValidServerAddress( + customMailboxServer, + { + allowPort: true, + requireHttps: true + } )) || pairingPhraseError || (pairingPhrase &&