diff --git a/portals/devportal/src/main/webapp/site/public/locales/en.json b/portals/devportal/src/main/webapp/site/public/locales/en.json index 7a88d84e4f7..ac129b88e94 100644 --- a/portals/devportal/src/main/webapp/site/public/locales/en.json +++ b/portals/devportal/src/main/webapp/site/public/locales/en.json @@ -98,12 +98,12 @@ "Apis.Details.APIKeys.ApiKeyListing.input.days.label": "Days", "Apis.Details.APIKeys.ApiKeyListing.input.days.placeholder": "Enter days", "Apis.Details.APIKeys.ApiKeyListing.input.ipAddress.label": "IP Address", - "Apis.Details.APIKeys.ApiKeyListing.input.ipAddress.placeholder": "e.g. 192.168.1.100", + "Apis.Details.APIKeys.ApiKeyListing.input.ipAddress.placeholder": "e.g. 192.168.1.100, 10.0.0.0/24", "Apis.Details.APIKeys.ApiKeyListing.input.keyType.label": "Key Type", "Apis.Details.APIKeys.ApiKeyListing.input.name.label": "Name", "Apis.Details.APIKeys.ApiKeyListing.input.name.placeholder": "Enter a name for this API key", "Apis.Details.APIKeys.ApiKeyListing.input.referrerUrl.label": "Referrer URL", - "Apis.Details.APIKeys.ApiKeyListing.input.referrerUrl.placeholder": "e.g. https://example.com", + "Apis.Details.APIKeys.ApiKeyListing.input.referrerUrl.placeholder": "e.g. https://example.com/*", "Apis.Details.APIKeys.ApiKeyListing.input.securityRestriction.label": "Security Restriction", "Apis.Details.APIKeys.ApiKeyListing.input.validityPeriod.label": "Validity Period", "Apis.Details.APIKeys.ApiKeyListing.keyType.production": "Production", @@ -855,6 +855,8 @@ "Shared.AppsAndKeys.LegacyApiKeys.alert.enterRestrictionValue": "Please enter a {restrictionLabel} value.", "Shared.AppsAndKeys.LegacyApiKeys.alert.generateFailed": "Failed to generate API key. Please try again.", "Shared.AppsAndKeys.LegacyApiKeys.alert.invalidCustomDays": "Please enter a valid positive number of days for custom validity period.", + "Shared.AppsAndKeys.LegacyApiKeys.alert.invalidIPRestriction": "Invalid IP address. Enter a valid IPv4/IPv6 address or CIDR range, separating multiple values with commas.", + "Shared.AppsAndKeys.LegacyApiKeys.alert.invalidRefererRestriction": "Invalid referrer. Enter a URL or a pattern with * wildcards (e.g. https://example.com/*), separating multiple values with commas.", "Shared.AppsAndKeys.LegacyApiKeys.button.cancel": "Cancel", "Shared.AppsAndKeys.LegacyApiKeys.button.close": "Close", "Shared.AppsAndKeys.LegacyApiKeys.button.generate": "Generate Legacy API Key", @@ -874,11 +876,11 @@ "Shared.AppsAndKeys.LegacyApiKeys.field.days.label": "Days", "Shared.AppsAndKeys.LegacyApiKeys.field.days.placeholder": "Enter days", "Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.label": "IP Address", - "Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.placeholder": "e.g. 192.168.1.100", + "Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.placeholder": "e.g. 192.168.1.100, 10.0.0.0/24", "Shared.AppsAndKeys.LegacyApiKeys.field.name.label": "Name", "Shared.AppsAndKeys.LegacyApiKeys.field.name.placeholder": "Enter a name for this API key", "Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.label": "Referrer URL", - "Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.placeholder": "e.g. https://example.com", + "Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.placeholder": "e.g. https://example.com/*", "Shared.AppsAndKeys.LegacyApiKeys.field.securityRestriction.label": "Security Restriction", "Shared.AppsAndKeys.LegacyApiKeys.field.validityPeriod.label": "Validity Period", "Shared.AppsAndKeys.LegacyApiKeys.generatedKey.alert.message": "Please copy this generated API Key value as it will be displayed only for the current browser session. (The API Key will not be visible in the UI after the page is refreshed.)", @@ -1086,4 +1088,4 @@ "subscription.pending": "Your subscription request has been submitted and is now awaiting approval.", "subscription.tierPending": "Your subscription update request has been submitted and is now awaiting approval.", "username": "Username" -} +} \ No newline at end of file diff --git a/portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js b/portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js index e11aff4f6b8..0611d8c2b7b 100644 --- a/portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js +++ b/portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js @@ -16,7 +16,7 @@ * under the License. */ -import validateConstraint, { VALIDATOR_TYPES, getConstraintHint } from 'AppComponents/Shared/AppsAndKeys/constraintValidator'; +import validateConstraint, { VALIDATOR_TYPES, getConstraintHint, isValidPermittedIPList, isValidPermittedRefererList } from 'AppComponents/Shared/AppsAndKeys/constraintValidator'; const mockIntl = { formatMessage: (msg, values) => { @@ -220,4 +220,70 @@ describe('constraintValidator', () => { expect(getConstraintHint(constraint, mockIntl, mockMessages)).toBe(''); }); }); +}); + +describe('API key restriction validators', () => { + describe('isValidPermittedIPList', () => { + it.each([ + ['192.168.1.100'], + ['0.0.0.0'], + ['255.255.255.255'], + ['10.0.0.0/24'], + ['192.168.1.0/32'], + ['2001:db8::1'], + ['::1'], + ['fe80::1%eth0'], + ['2001:db8::/64'], + ['::ffff:192.168.1.1'], + ['192.168.1.100, 10.0.0.0/24'], + ['192.168.1.100,2001:db8::1, 172.16.0.0/12'], + [' 192.168.1.100 '], + ])('should accept %s', (value) => { + expect(isValidPermittedIPList(value)).toBe(true); + }); + + it.each([ + [''], + [' '], + ['randomstring'], + ['999.1.1.1'], + ['192.168.1'], + ['192.168.1.100.5'], + ['192.168.1.100/33'], + ['2001:db8::/129'], + ['192.168.1.100/'], + ['192.168.1.100, notanip'], + ['192.168.1.100,,10.0.0.1'], + ['192.168.1.100 10.0.0.1'], + ['2001:zz8::1'], + ])('should reject %s', (value) => { + expect(isValidPermittedIPList(value)).toBe(false); + }); + }); + + describe('isValidPermittedRefererList', () => { + it.each([ + ['https://example.com'], + ['https://example.com/*'], + ['www.example.com/path'], + ['*.example.com'], + ['example.com'], + ['localhost:3000/*'], + ['https://a.com, https://b.com/*'], + [' https://example.com '], + ])('should accept %s', (value) => { + expect(isValidPermittedRefererList(value)).toBe(true); + }); + + it.each([ + [''], + [' '], + ['randomstring'], + ['https://example.com, randomstring'], + ['https://exa mple.com'], + ['https://a.com,,https://b.com'], + ])('should reject %s', (value) => { + expect(isValidPermittedRefererList(value)).toBe(false); + }); + }); }); \ No newline at end of file diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx index f7f4a523e14..44a6c0fe7cf 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyGenerate.jsx @@ -37,6 +37,7 @@ import { import { ContentCopy, Refresh } from '@mui/icons-material'; import API from 'AppData/api'; import Alert from 'AppComponents/Shared/Alert'; +import { validateRestrictionOrAlert } from 'AppComponents/Shared/AppsAndKeys/LegacyApiKeys'; /** * Custom hook for managing API key generation and regeneration operations @@ -54,6 +55,7 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { const [customValidityDays, setCustomValidityDays] = React.useState(''); const [restrictionType, setRestrictionType] = React.useState('none'); const [restrictionValue, setRestrictionValue] = React.useState(''); + const [restrictionError, setRestrictionError] = React.useState(''); // Generation modal state const [generationModalOpen, setGenerationModalOpen] = React.useState(false); @@ -140,6 +142,7 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { setCustomValidityDays(''); setRestrictionType('none'); setRestrictionValue(''); + setRestrictionError(''); setShowToken(false); setApikey(null); // Refresh the API keys list after closing @@ -166,6 +169,7 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { )); return; } + if (!validateRestrictionOrAlert(restrictionType, restrictionValue, intl, setRestrictionError)) return; if (validityPeriod === 'custom' && !customValidityDays) { const customDays = Number(customValidityDays); if (!Number.isInteger(customDays) || customDays <= 0) { @@ -436,6 +440,8 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) { setRestrictionType, restrictionValue, setRestrictionValue, + restrictionError, + setRestrictionError, generationModalOpen, isGenerating, apikey, diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx index a40e0193043..5eb62d8639b 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Apis/Details/APIKeys/ApiKeyListing.jsx @@ -215,6 +215,8 @@ export default function ApiKeyListing() { setRestrictionType, restrictionValue, setRestrictionValue, + restrictionError, + setRestrictionError, generationModalOpen, isGenerating, apikey, @@ -743,6 +745,7 @@ export default function ApiKeyListing() { onChange={(e) => { setRestrictionType(e.target.value); setRestrictionValue(''); + setRestrictionError(''); }} > {restrictionOptions.map((option) => ( @@ -768,16 +771,21 @@ export default function ApiKeyListing() { defaultMessage: 'Referrer URL', })} value={restrictionValue} - onChange={(e) => setRestrictionValue(e.target.value)} + onChange={(e) => { + setRestrictionValue(e.target.value); + setRestrictionError(''); + }} placeholder={restrictionType === 'ip' ? intl.formatMessage({ id: 'Apis.Details.APIKeys.ApiKeyListing.input.ipAddress.placeholder', - defaultMessage: 'e.g. 192.168.1.100', + defaultMessage: 'e.g. 192.168.1.100, 10.0.0.0/24', }) : intl.formatMessage({ id: 'Apis.Details.APIKeys.ApiKeyListing.input.referrerUrl.placeholder', - defaultMessage: 'e.g. https://example.com', + defaultMessage: 'e.g. https://example.com/*', })} + error={Boolean(restrictionError)} + helperText={restrictionError} sx={{ minWidth: 200 }} /> diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/LegacyApiKeys.jsx b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/LegacyApiKeys.jsx index 83544b40d33..58e132cce8d 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/LegacyApiKeys.jsx +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/LegacyApiKeys.jsx @@ -50,7 +50,31 @@ import { import MUIDataTable from 'mui-datatables'; import API from 'AppData/api'; import Alert from 'AppComponents/Shared/Alert'; - +import { validateRestrictionValue } from 'AppComponents/Shared/AppsAndKeys/constraintValidator'; + +// Message descriptors for the Security Restriction validation errors, shared with the per-API key generation dialog (ApiKeyGenerate). +export const restrictionMessages = defineMessages({ + invalidIPRestriction: { + id: 'Shared.AppsAndKeys.LegacyApiKeys.alert.invalidIPRestriction', + defaultMessage: 'Invalid IP address. Enter a valid IPv4/IPv6 address or CIDR range,' + + ' separating multiple values with commas.', + }, + invalidRefererRestriction: { + id: 'Shared.AppsAndKeys.LegacyApiKeys.alert.invalidRefererRestriction', + defaultMessage: 'Invalid referrer. Enter a URL or a pattern with * wildcards' + + ' (e.g. https://example.com/*), separating multiple values with commas.', + }, +}); + +export const validateRestrictionOrAlert = (restrictionType, restrictionValue, intl, setRestrictionError) => { + const message = validateRestrictionValue(restrictionType, restrictionValue, intl, restrictionMessages); + setRestrictionError(message); + if (message) { + Alert.error(message); + return false; + } + return true; +}; /** * LegacyApiKeys component for managing legacy API keys * @param {object} props - Component props @@ -89,6 +113,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) { const [customValidityDays, setCustomValidityDays] = React.useState(''); const [restrictionType, setRestrictionType] = React.useState('none'); const [restrictionValue, setRestrictionValue] = React.useState(''); + const [restrictionError, setRestrictionError] = React.useState(''); /** * Gets all the legacy API keys for a particular application @@ -164,6 +189,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) { setCustomValidityDays(''); setRestrictionType('none'); setRestrictionValue(''); + setRestrictionError(''); }; const handleCloseGeneratedKeyModal = () => { @@ -192,6 +218,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) { )); return; } + if (!validateRestrictionOrAlert(restrictionType, restrictionValue, intl, setRestrictionError)) return; if (validityPeriod === 'custom' && !customValidityDays) { const customDays = Number(customValidityDays); if (!Number.isInteger(customDays) || customDays <= 0) { @@ -619,6 +646,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) { onChange={(e) => { setRestrictionType(e.target.value); setRestrictionValue(''); + setRestrictionError(''); }} > {restrictionOptions.map((option) => ( @@ -638,10 +666,15 @@ export default function LegacyApiKeys({ keyType, selectedApp }) { ? intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.label', defaultMessage: 'IP Address' }) : intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.label', defaultMessage: 'Referrer URL' })} value={restrictionValue} - onChange={(e) => setRestrictionValue(e.target.value)} + onChange={(e) => { + setRestrictionValue(e.target.value); + setRestrictionError(''); + }} placeholder={restrictionType === 'ip' - ? intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.placeholder', defaultMessage: 'e.g. 192.168.1.100' }) - : intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.placeholder', defaultMessage: 'e.g. https://example.com' })} + ? intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.ipAddress.placeholder', defaultMessage: 'e.g. 192.168.1.100, 10.0.0.0/24' }) + : intl.formatMessage({ id: 'Shared.AppsAndKeys.LegacyApiKeys.field.referrerUrl.placeholder', defaultMessage: 'e.g. https://example.com/*' })} + error={Boolean(restrictionError)} + helperText={restrictionError} /> )} diff --git a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/constraintValidator.js b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/constraintValidator.js index 54da95960c6..5577dfef17d 100644 --- a/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/constraintValidator.js +++ b/portals/devportal/src/main/webapp/source/src/app/components/Shared/AppsAndKeys/constraintValidator.js @@ -42,6 +42,27 @@ export const VALIDATOR_TYPES = { REGEX: 'REGEX', }; +/* + * Validators for API key security restrictions. + * IP validation supports IPv4, IPv6, and CIDR ranges. + * Referer values are validated separately and support '*' wildcards. + * The IPv4/IPv6 patterns are reused from the Admin Portal blacklist form (Throttling/Blacklist/AddEdit.jsx) so both portals accept the same formats. + */ +const IPV4_PATTERN = String.raw`(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}` + + '([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])'; +const IPV6_PATTERN = '(([0-9a-fA-F]{1,4}:){7,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}|:)' + + '|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,' + + String.raw`1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:(` + + String.raw`(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`; + +const IPV4_REGEX = new RegExp(`^${IPV4_PATTERN}$`); +const IPV6_REGEX = new RegExp(`^${IPV6_PATTERN}$`); +const IPV4_CIDR_REGEX = new RegExp(`^${IPV4_PATTERN}/([0-9]|[12][0-9]|3[0-2])$`); +const IPV6_CIDR_REGEX = new RegExp(`^${IPV6_PATTERN}/([0-9]|[1-9][0-9]|1[01][0-9]|12[0-8])$`); + /** * Returns a localized hint string for a constraint. * @param {object|null|undefined} constraint - The constraint object from config. @@ -150,4 +171,62 @@ const validateConstraint = (inputValue, constraint, intl, messages) => { } }; -export default validateConstraint; +/** + * Checks an IP based restriction value: a single IPv4/IPv6 address or CIDR range, or a comma separated list of them. + * + * @param {string} value - The raw user input. + * @returns {boolean} true if every entry is a valid IP address or CIDR range. + */ +export const isValidPermittedIPList = (value) => { + if (!value?.trim()) { + return false; + } + return value.split(',').every((entry) => { + const ip = entry.trim(); + return ip !== '' + && (IPV4_REGEX.test(ip) + || IPV6_REGEX.test(ip) + || IPV4_CIDR_REGEX.test(ip) + || IPV6_CIDR_REGEX.test(ip)); + }); +}; + +/** + * Validates a permitted referrer value. + * Supports single or comma-separated entries with '*' wildcards. + * + * @param {string} value - The raw user input. + * @returns {boolean} true if every entry is a plausible referrer pattern. + */ +export const isValidPermittedRefererList = (value) => { + if (!value?.trim()) { + return false; + } + return value.split(',').every((entry) => { + const referer = entry.trim(); + return referer !== '' + && !/\s/.test(referer) + && /[.:/*]/.test(referer); + }); +}; + +/** + * Validates an API key security restriction value and returns the corresponding localized validation message. + * + * @param {string} restrictionType - The selected restriction type ('none', 'ip' or 'referrer'). + * @param {string} restrictionValue - The raw user input. + * @param {object} intl - The intl object for formatMessage. + * @param {object} messages - defineMessages result with invalidIPRestriction and invalidRefererRestriction. + * @returns {string} Localized error message, or '' if valid. + */ +export const validateRestrictionValue = (restrictionType, restrictionValue, intl, messages) => { + if (restrictionType === 'ip' && !isValidPermittedIPList(restrictionValue)) { + return intl.formatMessage(messages.invalidIPRestriction); + } + if (restrictionType === 'referrer' && !isValidPermittedRefererList(restrictionValue)) { + return intl.formatMessage(messages.invalidRefererRestriction); + } + return ''; +}; + +export default validateConstraint; \ No newline at end of file