diff --git a/assets/images/help/copilot/chat-general-purpose-button.png b/assets/images/help/copilot/chat-general-purpose-button.png index a4656a652816..b7ab0a88d051 100644 Binary files a/assets/images/help/copilot/chat-general-purpose-button.png and b/assets/images/help/copilot/chat-general-purpose-button.png differ diff --git a/assets/images/help/copilot/chat-new-conversation-button.png b/assets/images/help/copilot/chat-new-conversation-button.png index 9e080236da07..5a79ae4e70de 100644 Binary files a/assets/images/help/copilot/chat-new-conversation-button.png and b/assets/images/help/copilot/chat-new-conversation-button.png differ diff --git a/assets/images/help/copilot/copilot-chat-all-repositories.png b/assets/images/help/copilot/copilot-chat-all-repositories.png index 098cb5ea49dd..38f890b1e6b9 100644 Binary files a/assets/images/help/copilot/copilot-chat-all-repositories.png and b/assets/images/help/copilot/copilot-chat-all-repositories.png differ diff --git a/content/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username.md b/content/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username.md index 9d75b2c01dde..6e7f216e3af0 100644 --- a/content/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username.md +++ b/content/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-user-account-settings/changing-your-github-username.md @@ -44,7 +44,7 @@ If you hold a trademark for the username, you can find more information about ma If you do not hold a trademark for the name, you can choose another username or keep your current username. {% data variables.contact.github_support %} cannot release the unavailable username for you. For more information, see "[Changing your username](#changing-your-username)."{% endif %} -After changing your username, your previous username will be unavailable for anyone to claim for 90 days. Most references to your repositories under the previous username automatically change to the new username. However, some links to your profile won't automatically redirect. +After changing your username, your old username becomes available for anyone else to claim. Most references to your repositories under the old username automatically change to the new username. However, some links to your profile won't automatically redirect. {% data variables.product.product_name %} cannot set up redirects for: * [@mentions](/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#mentioning-people-and-teams) using your old username diff --git a/content/admin/installing-your-enterprise-server/setting-up-a-github-enterprise-server-instance/setting-up-a-staging-instance.md b/content/admin/installing-your-enterprise-server/setting-up-a-github-enterprise-server-instance/setting-up-a-staging-instance.md index ce559281e08a..12a5ac235af7 100644 --- a/content/admin/installing-your-enterprise-server/setting-up-a-github-enterprise-server-instance/setting-up-a-staging-instance.md +++ b/content/admin/installing-your-enterprise-server/setting-up-a-github-enterprise-server-instance/setting-up-a-staging-instance.md @@ -61,6 +61,9 @@ Set up a new instance to act as your staging environment. You can use the same g If you plan to restore a backup of your production instance, continue to the next step. Alternatively, you can configure the instance manually and skip the following steps. +> [!WARNING] +> Restoring backups with {% data variables.product.prodname_actions %} will not succeed on a non-configured instance. To enable {% data variables.product.prodname_actions %}, an instance with a hostname configured is required. For more information, see "[AUTOTITLE](/admin/configuring-settings/configuring-network-settings/configuring-the-hostname-for-your-instance)." + ### 3. Configure {% data variables.product.prodname_actions %} Optionally, if you use {% data variables.product.prodname_actions %} on your production instance, configure the feature on the staging instance before restoring your production backup. If you don't use {% data variables.product.prodname_actions %}, skip to "[1. Configure {% data variables.product.prodname_registry %}](#4-configure-github-packages)." @@ -78,6 +81,12 @@ Optionally, if you use {% data variables.product.prodname_actions %} on your pro ghe-config app.actions.enabled true ``` +1. To apply the configuration changes, enter the following command. + + ```shell copy + ghe-config-apply + ``` + ### 4. Configure {% data variables.product.prodname_registry %} Optionally, if you use {% data variables.product.prodname_registry %} on your production instance, configure the feature on the staging instance before restoring your production backup. If you don't use {% data variables.product.prodname_registry %}, skip to "[1. Restore your production backup](#5-restore-your-production-backup)." diff --git a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/configuring-automatic-dependency-submission-for-your-repository.md b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/configuring-automatic-dependency-submission-for-your-repository.md index 3f11aadf2382..f40b39abdc6a 100644 --- a/content/code-security/supply-chain-security/understanding-your-software-supply-chain/configuring-automatic-dependency-submission-for-your-repository.md +++ b/content/code-security/supply-chain-security/understanding-your-software-supply-chain/configuring-automatic-dependency-submission-for-your-repository.md @@ -81,6 +81,8 @@ Once enabled, automatic dependency submission jobs will run on the self-hosted r Automatic dependency submission is currently only supported for Maven. The feature uses the Maven Dependency Tree Submission action. For more information, see the documentation for the [Maven Dependency Tree Dependency Submission](https://github.com/marketplace/actions/maven-dependency-tree-dependency-submission) action in the {% data variables.product.prodname_marketplace %}. If your project uses a non-standard Maven configuration, it may not properly generate the dependencies and submit them to the dependency graph. +Automatic dependency submission makes a best effort to cache package downloads between runs using the [Cache](https://github.com/marketplace/actions/cache) action to speed up workflows. For self-hosted runners, you may want to manage this cache within your own infrastructure. To do this, you can disable the built-in caching by setting an environment variable of `GH_DEPENDENCY_SUBMISSION_SKIP_CACHE` to `true`. For more information, see "[AUTOTITLE](/actions/learn-github-actions/variables)." + ## Further reading * "[AUTOTITLE](/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security)" diff --git a/content/copilot/using-github-copilot/asking-github-copilot-questions-in-github.md b/content/copilot/using-github-copilot/asking-github-copilot-questions-in-github.md index 37ac5789b155..cd8d4cd6209a 100644 --- a/content/copilot/using-github-copilot/asking-github-copilot-questions-in-github.md +++ b/content/copilot/using-github-copilot/asking-github-copilot-questions-in-github.md @@ -82,12 +82,12 @@ The skills you can use in {% data variables.product.prodname_copilot_chat_dotcom > [!NOTE] If you use {% data variables.product.prodname_copilot_extensions_short %}, they may override the model you select. {% data reusables.copilot.model-picker-enable-o1-models %} -1. In the bottom right of any page on {% data variables.product.github %}, click {% octicon "copilot" aria-label="Open Copilot Chat" %}. +1. In the top right of any page on {% data variables.product.github %}, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon next to the search bar. 1. If the panel contains a previous conversation you had with {% data variables.product.prodname_copilot_short %}, in the top right of the panel, click {% octicon "plus" aria-label="New conversation" %}. ![Screenshot of the new conversation button, highlighted with a dark orange outline.](/assets/images/help/copilot/chat-new-conversation-button.png) -1. In the top right of the panel, select the {% octicon "kebab-horizontal" aria-label="Open conversation options" %} dropdown menu, then click **{% octicon "screen-full" aria-hidden="true" %} Take conversation to immersive**. Multi-model {% data variables.product.prodname_copilot_chat_short %} is currently only available in the immersive view. +1. In the top right of the panel, click **{% octicon "screen-full" aria-hidden="true" %} Take conversation to immersive**. Multi-model {% data variables.product.prodname_copilot_chat_short %} is currently only available in the immersive view. 1. In the top left of the immersive view, select the **CURRENT-MODEL** {% octicon "chevron-down" aria-hidden="true" %} dropdown menu, then click the AI model of your choice. ## Asking a general question about software development @@ -131,7 +131,7 @@ Depending on the question you ask, and your enterprise and organization settings 1. On the {% data variables.product.prodname_dotcom %} website, go to the repository you want to chat about. -1. Click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon at the bottom right of the page. +1. In the top right of any page on {% data variables.product.github %}, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon next to the search bar. The {% data variables.product.prodname_copilot_chat %} panel is displayed. To resize the panel, click and drag the top or left edge. @@ -281,7 +281,7 @@ You can chat with {% data variables.product.prodname_copilot_short %} about a fi 1. You can continue the conversation by asking a follow-up question. For example, you could type "tell me more" to get {% data variables.product.prodname_copilot_short %} to expand on its last comment. 1. To clear, delete, or rename the current conversation thread, or to start a new thread, type `/` in the "Ask {% data variables.product.prodname_copilot_short %}" box, select from the options that are displayed, then press Enter. -1. To view a conversation in immersive mode, displaying just the conversation thread, click {% octicon "kebab-horizontal" aria-label="Open conversation options" %} at the top right of the conversation thread, then click **{% octicon "screen-full" aria-hidden="true" %} Take conversation to immersive**. +1. To view a conversation in immersive mode, displaying just the conversation thread, click **{% octicon "screen-full" aria-hidden="true" %} Take conversation to immersive**. ## Asking questions about {% data variables.product.prodname_GH_advanced_security %} alerts @@ -463,7 +463,7 @@ You can ask {% data variables.product.prodname_copilot_short %} a question about To give feedback about a particular {% data variables.product.prodname_copilot_chat_short %} response, click either the thumbs up or thumbs down icon at the bottom of each chat response. -To give feedback about {% data variables.product.prodname_copilot_chat_short %} in general, click the ellipsis (**...**) at the top right of the chat panel, then click **Give feedback**. +To give feedback about {% data variables.product.prodname_copilot_chat_short %} in general, click the ellipsis (**...**) at the top right of the chat panel, then click **{% octicon "comment-discussion" aria-hidden="true" %} Give feedback**. ## Further reading diff --git a/content/organizations/managing-organization-settings/deleting-an-organization-account.md b/content/organizations/managing-organization-settings/deleting-an-organization-account.md index d35ce825996b..f3035e6cb895 100644 --- a/content/organizations/managing-organization-settings/deleting-an-organization-account.md +++ b/content/organizations/managing-organization-settings/deleting-an-organization-account.md @@ -25,6 +25,12 @@ shortTitle: Delete organization {% endif %} Deleting your organization account removes all repositories, forks of private repositories, wikis, issues, pull requests, and project or organization pages. {% ifversion fpt or ghec %}Your billing will end and, after 90 days, the organization name becomes available for use on a new user or organization account. +{% tip %} + +**Tip**: If you rename an organization, you can create a new organization with the same name immediately. For more information, see "[AUTOTITLE](/organizations/managing-organization-settings/renaming-an-organization)." + +{% endtip %} + {% endif %} {% ifversion fpt or ghec %} diff --git a/content/organizations/managing-organization-settings/renaming-an-organization.md b/content/organizations/managing-organization-settings/renaming-an-organization.md index b6e377215a8c..832b442d2507 100644 --- a/content/organizations/managing-organization-settings/renaming-an-organization.md +++ b/content/organizations/managing-organization-settings/renaming-an-organization.md @@ -19,7 +19,7 @@ topics: ## What happens when I change my organization's name? -After changing your organization's name, your previous organization name will be unavailable for anyone to claim for 90 days. When you change your organization's name, most references to your repositories under the previous organization name automatically change to the new name. However, some links to your profile won't automatically redirect. +After changing your organization's name, your old organization name becomes available for someone else to claim. When you change your organization's name, most references to your repositories under the old organization name automatically change to the new name. However, some links to your profile won't automatically redirect. ### Changes that occur automatically diff --git a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/troubleshooting-rules.md b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/troubleshooting-rules.md index f4f6cd092474..4774988fda2f 100644 --- a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/troubleshooting-rules.md +++ b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/troubleshooting-rules.md @@ -17,6 +17,8 @@ Depending on which rules are active, you may need to edit your commit history lo If a branch or tag is targeted by rules restricting the metadata of commits, your commits may be rejected if part of the commit's metadata does not match a certain pattern. For example, you might need to add an issue number to the start of your commit message, or change the name of a new branch or tag you're trying to push to the repository. If your commits are rejected, you will see a message telling you the pattern the relevant metadata needs to match. As with signed commits, you may need to perform a rebase to squash the commits or rewrite each commit individually. For more information, see "[AUTOTITLE](/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#metadata-restrictions)." +When utilizing push rulesets, a maximum of 1000 reference updates are allowed per push. If your push exceeds this limit, it will be rejected. For more information see "[AUTOTITLE](/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository#creating-a-push-ruleset)". + {% ifversion repo-rules-required-workflows %} ## Troubleshooting ruleset workflows diff --git a/data/reusables/copilot/chat-conversation-buttons.md b/data/reusables/copilot/chat-conversation-buttons.md index 91250f4333ad..fd8a536c1c60 100644 --- a/data/reusables/copilot/chat-conversation-buttons.md +++ b/data/reusables/copilot/chat-conversation-buttons.md @@ -1,3 +1 @@ -1. To jump back into a previous conversation you had with {% data variables.product.prodname_copilot_short %}, click the "View conversations" icon (a clock face surrounded by a circular arrow) at the top right of the panel. - - ![Screenshot of the "Conversation history" icon, highlighted with a dark orange outline.](/assets/images/help/copilot/chat-view-conversations-button.png) +1. To jump back into a previous conversation you had with {% data variables.product.prodname_copilot_short %}, select the {% octicon "kebab-horizontal" aria-label="Open conversation options" %} dropdown menu, then click **{% octicon "history" aria-hidden="true" %} View all conversations**. diff --git a/data/reusables/copilot/go-to-copilot-page.md b/data/reusables/copilot/go-to-copilot-page.md index a7596014ea32..1bc4b9df8bc9 100644 --- a/data/reusables/copilot/go-to-copilot-page.md +++ b/data/reusables/copilot/go-to-copilot-page.md @@ -1,4 +1,4 @@ -1. On any page on {% data variables.product.github %}, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon at the bottom right of the page. +1. In the top right of any page on {% data variables.product.github %}, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon next to the search bar. The {% data variables.product.prodname_copilot_chat %} panel is displayed. To resize the panel, click and drag the top or left edge. diff --git a/data/reusables/copilot/open-copilot.md b/data/reusables/copilot/open-copilot.md index 3bd19e4e62e3..d7f935fe6416 100644 --- a/data/reusables/copilot/open-copilot.md +++ b/data/reusables/copilot/open-copilot.md @@ -1,4 +1,4 @@ -1. At the bottom right of the page, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon. +1. In the top right of any page on {% data variables.product.github %}, click the **{% octicon "copilot" aria-hidden="true" %}** {% data variables.product.prodname_copilot %} icon next to the search bar. The {% data variables.product.prodname_copilot_chat %} panel is displayed. To resize the panel, click and drag the top or left edge. diff --git a/src/audit-logs/lib/config.json b/src/audit-logs/lib/config.json index 4c619112b420..281047669120 100644 --- a/src/audit-logs/lib/config.json +++ b/src/audit-logs/lib/config.json @@ -3,5 +3,5 @@ "apiOnlyEvents": "This event is not available in the web interface, only via the REST API, audit log streaming, or JSON/CSV exports.", "apiRequestEvent": "This event is only available via audit log streaming." }, - "sha": "014cf4c9c23bf1f27dd034a2a76b92388951c01c" + "sha": "49363b4aeeaf1b397f113178250e939829c4c684" } \ No newline at end of file diff --git a/src/search/middleware/ai-search.ts b/src/search/middleware/ai-search.ts index f2cf89fbc724..6ca8601f83dc 100644 --- a/src/search/middleware/ai-search.ts +++ b/src/search/middleware/ai-search.ts @@ -2,8 +2,16 @@ import express, { Request, Response } from 'express' import catchMiddlewareError from '#src/observability/middleware/catch-middleware-error.js' import { aiSearchProxy } from '../lib/ai-search-proxy' +import { createRateLimiter } from '#src/shielding/middleware/rate-limit.js' const router = express.Router() +if (process.env.NODE_ENV === 'test') { + router.use(createRateLimiter(7)) // set to 7 so last test in api-ai-search.ts will exceed rate limit +} else if (process.env.NODE_ENV === 'development') { + router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed +} else if (process.env.NODE_ENV === 'production') { + router.use(createRateLimiter(1)) // 1 * 25 requests per minute for prod +} router.post( '/v1', diff --git a/src/search/middleware/search-routes.ts b/src/search/middleware/search-routes.ts index db84b46639f3..b7e7f89bed06 100644 --- a/src/search/middleware/search-routes.ts +++ b/src/search/middleware/search-routes.ts @@ -16,8 +16,14 @@ import { getAutocompleteSearchResults } from '@/search/lib/get-elasticsearch-res import { getAISearchAutocompleteResults } from '@/search/lib/get-elasticsearch-results/ai-search-autocomplete' import { getSearchFromRequestParams } from '@/search/lib/search-request-params/get-search-from-request-params' import { getGeneralSearchResults } from '@/search/lib/get-elasticsearch-results/general-search' +import { createRateLimiter } from '#src/shielding/middleware/rate-limit.js' const router = express.Router() +if (process.env.NODE_ENV === 'development') { + router.use(createRateLimiter(10)) // just 1 worker in dev so 10 requests per minute allowed +} else if (process.env.NODE_ENV === 'production') { + router.use(createRateLimiter(1)) // 1 * 25 requests per minute for prod +} router.get('/legacy', (req: Request, res: Response) => { res.status(410).send('Use /api/search/v1 instead.') diff --git a/src/search/tests/api-ai-search.ts b/src/search/tests/api-ai-search.ts index 9b66f2c6db30..0acce0581e7f 100644 --- a/src/search/tests/api-ai-search.ts +++ b/src/search/tests/api-ai-search.ts @@ -1,3 +1,6 @@ +// IMPORTANT: If you add more tests to this that make requests to +// http://localhost:4000/api/ai-search/v1 make sure you increment the rate limit +// value when NODE_ENV === 'test' in src/search/middleware/ai-search.ts import { expect, test, describe, beforeAll, afterAll } from 'vitest' import { post } from 'src/tests/helpers/e2etest.js' @@ -79,6 +82,41 @@ describe('AI Search Routes', () => { expect(receivedMessage).toBe(expectedMessage) }) + // We can't actually trigger a full rate limit because + // then all other tests will all fail. And we can't rely on this + // test always being run last. + test('should respect rate limiting', async () => { + let apiBody = { query: 'How do I create a Repository?', language: 'en', version: 'dotcom' } + + const response = await fetch('http://localhost:4000/api/ai-search/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiBody), + }) + + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + const limit = parseInt(response.headers.get('ratelimit-limit') || '0') + const remaining = parseInt(response.headers.get('ratelimit-remaining') || '0') + expect(limit).toBeGreaterThan(0) + expect(remaining).toBeLessThan(limit) + + const response2 = await fetch('http://localhost:4000/api/ai-search/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiBody), + }) + + expect(response2.ok).toBe(true) + expect(response2.status).toBe(200) + const newLimit = parseInt(response2.headers.get('ratelimit-limit') || '0') + const newRemaining = parseInt(response2.headers.get('ratelimit-remaining') || '0') + expect(newLimit).toBe(limit) + // Can't rely on `newRemaining == remaining - 1` because of + // concurrency of test-running. + expect(newRemaining).toBeLessThan(remaining) + }) + test('should handle validation errors: query missing', async () => { let body = { language: 'en', version: 'dotcom' } const response = await post('/api/ai-search/v1', { @@ -145,4 +183,17 @@ describe('AI Search Routes', () => { }, ]) }) + + test('should rate limit when total number of requests exceeds max amount', async () => { + let apiBody = { query: 'How do I create a Repository?', language: 'en', version: 'dotcom' } + + const response = await fetch('http://localhost:4000/api/ai-search/v1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(apiBody), + }) + + expect(response.ok).toBe(false) + expect(response.status).toBe(429) + }) }) diff --git a/src/secret-scanning/data/public-docs.yml b/src/secret-scanning/data/public-docs.yml index 6980ee6902cb..fe5204d0cccc 100644 --- a/src/secret-scanning/data/public-docs.yml +++ b/src/secret-scanning/data/public-docs.yml @@ -2994,7 +2994,7 @@ fpt: '*' ghec: '*' ghes: '>=3.15' - isPublic: false + isPublic: true isPrivateWithGhas: true hasPushProtection: false hasValidityCheck: false diff --git a/src/secret-scanning/lib/config.json b/src/secret-scanning/lib/config.json index 027c22d3693f..eb1ac49f4f09 100644 --- a/src/secret-scanning/lib/config.json +++ b/src/secret-scanning/lib/config.json @@ -1,5 +1,5 @@ { - "sha": "2d12e72e667b4842e181cadaff7ad619e356d5d1", - "blob-sha": "b1855224a34264029de0f0e7d8967ac1ee009419", + "sha": "a24682e7de2b1053278de2c44d4062aa80ffac33", + "blob-sha": "2eedb5973d30edfcf497b0810c36ffc8a061b474", "targetFilename": "code-security/secret-scanning/introduction/supported-secret-scanning-patterns" } \ No newline at end of file diff --git a/src/shielding/middleware/index.ts b/src/shielding/middleware/index.ts index f17fe584cfcf..c0ea035c1125 100644 --- a/src/shielding/middleware/index.ts +++ b/src/shielding/middleware/index.ts @@ -6,11 +6,11 @@ import handleOldNextDataPaths from './handle-old-next-data-paths' import handleInvalidQuerystringValues from './handle-invalid-query-string-values' import handleInvalidNextPaths from './handle-invalid-nextjs-paths' import handleInvalidHeaders from './handle-invalid-headers' -import rateLimit from './rate-limit' +import { createRateLimiter } from './rate-limit' const router = express.Router() -router.use(rateLimit) +router.use(createRateLimiter()) router.use(handleInvalidQuerystrings) router.use(handleInvalidPaths) router.use(handleOldNextDataPaths) diff --git a/src/shielding/middleware/rate-limit.ts b/src/shielding/middleware/rate-limit.ts index 7fb055e4f6d2..0c7f6b92165c 100644 --- a/src/shielding/middleware/rate-limit.ts +++ b/src/shielding/middleware/rate-limit.ts @@ -14,59 +14,64 @@ if (isNaN(MAX)) { const ipv4WithPort = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):\d{1,5}$/ -export default rateLimit({ - // 1 minute - windowMs: EXPIRES_IN_AS_SECONDS * 1000, - // limit each IP to X requests per windowMs - // We currently have about 25 instances in production. That's routed - // in Azure to spread the requests to each healthy instance. - // So, the true rate limit, per `windowMs`, is this number multiplied - // by the current number of instances. - max: MAX, - - // Return rate limit info in the `RateLimit-*` headers - standardHeaders: true, - // Disable the `X-RateLimit-*` headers - legacyHeaders: false, - - keyGenerator: (req) => { - let { ip } = req - // In our Azure preview environment, with the way the proxying works, - // the `x-forwarded-for` is always the origin IP with a port number - // attached. E.g. `75.40.90.27:56675, 169.254.129.1` - // This port number portion changes with every request, so we strip it. - ip = (ip || '').replace(ipv4WithPort, '$1') - - return ip - }, - - skip: (req) => { - // Always ignore these - if (req.path === '/api/events') return true - // If the query string looks totally regular, then skip - if (!isSuspiciousRequest(req)) return true - - // This is so we can get a sense of how many requests are being - // treated as suspicious. They don't necessarily get rate limited. - const tags = [ - `url:${req.url}`, - `ip:${req.ip}`, - `path:${req.path}`, - `qs:${req.url.split('?')[1]}`, - ] - - statsd.increment('middleware.rate_limit_dont_skip', 1, tags) - - return false - }, - - handler: (req, res, next, options) => { - const tags = [`url:${req.url}`, `ip:${req.ip}`, `path:${req.path}`] - statsd.increment('middleware.rate_limit', 1, tags) - noCacheControl(res) - res.status(options.statusCode).send(options.message) - }, -}) +export function createRateLimiter(max = MAX) { + return rateLimit({ + // 1 minute + windowMs: EXPIRES_IN_AS_SECONDS * 1000, + // limit each IP to X requests per windowMs + // We currently have about 25 instances in production. That's routed + // in Azure to spread the requests to each healthy instance. + // So, the true rate limit, per `windowMs`, is this number multiplied + // by the current number of instances. + max: max, + + // Return rate limit info in the `RateLimit-*` headers + standardHeaders: true, + // Disable the `X-RateLimit-*` headers + legacyHeaders: false, + + keyGenerator: (req) => { + let { ip } = req + // In our Azure preview environment, with the way the proxying works, + // the `x-forwarded-for` is always the origin IP with a port number + // attached. E.g. `75.40.90.27:56675, 169.254.129.1` + // This port number portion changes with every request, so we strip it. + ip = (ip || '').replace(ipv4WithPort, '$1') + + return ip + }, + + skip: (req) => { + // Always ignore these + if (req.path === '/api/events') return true + // Always rate limit these routes + const dontSkip = + req.originalUrl.includes('/api/search') || req.originalUrl.includes('/api/ai-search') + // If the query string looks totally regular, then skip + if (!isSuspiciousRequest(req) && !dontSkip) return true + + // This is so we can get a sense of how many requests are being + // treated as suspicious. They don't necessarily get rate limited. + const tags = [ + `url:${req.url}`, + `ip:${req.ip}`, + `path:${req.path}`, + `qs:${req.url.split('?')[1]}`, + ] + + statsd.increment('middleware.rate_limit_dont_skip', 1, tags) + + return false + }, + + handler: (req, res, next, options) => { + const tags = [`url:${req.url}`, `ip:${req.ip}`, `path:${req.path}`] + statsd.increment('middleware.rate_limit', 1, tags) + noCacheControl(res) + res.status(options.statusCode).send(options.message) + }, + }) +} const RECOGNIZED_KEYS_BY_PREFIX = { '/_next/data/': ['versionId', 'productId', 'restPage', 'apiVersion', 'category', 'subcategory'],