Skip to content
Open
12 changes: 7 additions & 5 deletions portals/devportal/src/main/webapp/site/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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.)",
Expand Down Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -220,4 +220,70 @@
expect(getConstraintHint(constraint, mockIntl, mockMessages)).toBe('');
});
});
});

describe('API key restriction validators', () => {
describe('isValidPermittedIPList', () => {
it.each([
['192.168.1.100'],

Check warning on line 228 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 192.168.1.100 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qm&open=AZ9rNPAmIc2FEvJ1i1qm&pullRequest=1393
['0.0.0.0'],
['255.255.255.255'],
['10.0.0.0/24'],

Check warning on line 231 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 10.0.0.0/24 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qn&open=AZ9rNPAmIc2FEvJ1i1qn&pullRequest=1393
['192.168.1.0/32'],

Check warning on line 232 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 192.168.1.0/32 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qo&open=AZ9rNPAmIc2FEvJ1i1qo&pullRequest=1393
['2001:db8::1'],
['::1'],
['fe80::1%eth0'],

Check warning on line 235 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address fe80::1%eth0 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qp&open=AZ9rNPAmIc2FEvJ1i1qp&pullRequest=1393
['2001:db8::/64'],
['::ffff:192.168.1.1'],

Check warning on line 237 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address ::ffff:192.168.1.1 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qq&open=AZ9rNPAmIc2FEvJ1i1qq&pullRequest=1393
['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'],

Check warning on line 252 in portals/devportal/src/main/webapp/source/Tests/Unit/constraintValidator.test.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using a hardcoded IP address 192.168.1.100/33 is safe here.

See more on https://sonarcloud.io/project/issues?id=wso2_apim-apps&issues=AZ9rNPAmIc2FEvJ1i1qr&open=AZ9rNPAmIc2FEvJ1i1qr&pullRequest=1393
['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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -166,6 +169,7 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) {
));
return;
}
if (!validateRestrictionOrAlert(restrictionType, restrictionValue, intl, setRestrictionError)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (validityPeriod === 'custom' && !customValidityDays) {
const customDays = Number(customValidityDays);
if (!Number.isInteger(customDays) || customDays <= 0) {
Expand Down Expand Up @@ -436,6 +440,8 @@ export default function ApiKeyGenerate(apiUUID, refreshApiKeys) {
setRestrictionType,
restrictionValue,
setRestrictionValue,
restrictionError,
setRestrictionError,
generationModalOpen,
isGenerating,
apikey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ export default function ApiKeyListing() {
setRestrictionType,
restrictionValue,
setRestrictionValue,
restrictionError,
setRestrictionError,
generationModalOpen,
isGenerating,
apikey,
Expand Down Expand Up @@ -743,6 +745,7 @@ export default function ApiKeyListing() {
onChange={(e) => {
setRestrictionType(e.target.value);
setRestrictionValue('');
setRestrictionError('');
}}
>
{restrictionOptions.map((option) => (
Expand All @@ -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 }}
/>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -164,6 +189,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) {
setCustomValidityDays('');
setRestrictionType('none');
setRestrictionValue('');
setRestrictionError('');
};

const handleCloseGeneratedKeyModal = () => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -619,6 +646,7 @@ export default function LegacyApiKeys({ keyType, selectedApp }) {
onChange={(e) => {
setRestrictionType(e.target.value);
setRestrictionValue('');
setRestrictionError('');
}}
>
{restrictionOptions.map((option) => (
Expand All @@ -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}
/>
</Grid>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;