Repository: strapi/strapi
File: packages/core/admin/server/src/controllers/webhooks.ts (TypeScript)

Code before:
} from '../../../shared/contracts/webhooks';

const urlRegex =
  /^(?:([a-z0-9+.-]+):\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9_]-*)*[a-z\u00a1-\uffff0-9_]+)(?:\.(?:[a-z\u00a1-\uffff0-9_]-*)*[a-z\u00a1-\uffff0-9_]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/;

const webhookValidator = yup
  .object({
    name: yup.string().required(),
    url: yup
      .string()
      .matches(urlRegex, 'url must be a valid URL')
      .required()
      .test(
        'is-public-url',
        "Url is not supported because it isn't reachable over the public internet",
        async (url) => {
          if (process.env.NODE_ENV !== 'production') {
            return true;
          }

          try {
            const parsedUrl = new URL(url!);
            const isLocalUrl = await isLocalhostIp(parsedUrl.hostname);
            return !isLocalUrl;
          } catch {
            return false;
          }
        }
      ),
    headers: yup.lazy((data) => {
      if (typeof data !== 'object') {
        return yup.object().required();
      }

      return yup
        .object(
          // @ts-expect-error lodash types
          _.mapValues(data, () => {
            yup.string().min(1).required();
          })
        )
        .required();
    }),
    events: yup.array().of(yup.string()).required(),
  })
  .noUnknown();

const updateWebhookValidator = webhookValidator.shape({
  isEnabled: yup.boolean(),
});


Review comment (suggestion):
To be completely safe you need to use [punycode](https://www.npmjs.com/package/is-localhost-ip#caveats) on the url first since is-localhost-ip doesn't support international character sets by default.  Otherwise someone could, for example, use an internationalized domain name pointed at 127.0.0.1 to achieve the same effect.

Code after:
  UpdateWebhook,
  TriggerWebhook,
  GetWebhooks,
} from '../../../shared/contracts/webhooks';

const urlRegex =
  /^(?:([a-z0-9+.-]+):\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9_]-*)*[a-z\u00a1-\uffff0-9_]+)(?:\.(?:[a-z\u00a1-\uffff0-9_]-*)*[a-z\u00a1-\uffff0-9_]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/;

const webhookValidator = yup
  .object({
    name: yup.string().required(),
    url: yup
      .string()
      .matches(urlRegex, 'url must be a valid URL')
      .required()
      .test(
        'is-public-url',
        "Url is not supported because it isn't reachable over the public internet",
        async (url) => {
          if (process.env.NODE_ENV !== 'production') {
            return true;
          }

          try {
            const parsedUrl = new URL(punycode.toASCII(url!));
            const isLocalUrl = await isLocalhostIp(parsedUrl.hostname);
            return !isLocalUrl;
          } catch {
            return false;
          }
        }
      ),
    headers: yup.lazy((data) => {
      if (typeof data !== 'object') {
        return yup.object().required();
      }

      return yup
        .object(
          // @ts-expect-error lodash types
          _.mapValues(data, () => {
            yup.string().min(1).required();
          })
        )
        .required();
    }),
    events: yup.array().of(yup.string()).required(),
  })
  .noUnknown();

const updateWebhookValidator = webhookValidator.shape({