diff --git a/9.0.0.md b/9.0.0.md new file mode 100644 index 00000000..80ba6cf0 --- /dev/null +++ b/9.0.0.md @@ -0,0 +1,56 @@ +# Parse Server 9 Migration Guide + +This document only highlights specific changes that require a longer explanation. For a full list of changes in Parse Server 9 please refer to the [changelog](https://github.com/parse-community/parse-server/blob/alpha/CHANGELOG.md). + +--- +- [Route Path Syntax and Rate Limiting](#route-path-syntax-and-rate-limiting) +--- + +## Route Path Syntax and Rate Limiting +Parse Server 9 standardizes the route pattern syntax across cloud routes and rate-limiting to use the new **path-to-regexp v8** style. This update introduces validation and a clear deprecation error for the old wildcard route syntax. + +### Key Changes +- **Standardization**: All route paths now use the path-to-regexp v8 syntax, which provides better consistency and security. +- **Validation**: Added validation to ensure route paths conform to the new syntax. +- **Deprecation**: Old wildcard route syntax is deprecated and will trigger a clear error message. + +### Migration Steps + +#### Path Syntax Examples + +Update your rate limit configurations to use the new path-to-regexp v8 syntax: + +| Old Syntax (deprecated) | New Syntax (v8) | +|------------------------|-----------------| +| `/functions/*` | `/functions/*path` | +| `/classes/*` | `/classes/*path` | +| `/*` | `/*path` | +| `*` | `*path` | + +**Before:** +```javascript +rateLimit: { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 100 +} +``` + +**After:** +```javascript +rateLimit: { + requestPath: '/functions/*path', + requestTimeWindow: 10000, + requestCount: 100 +} +``` + +- Review your custom cloud routes and ensure they use the new path-to-regexp v8 syntax. +- Update any rate-limiting configurations to use the new route path format. +- Test your application to ensure all routes work as expected with the new syntax. + +> [!Note] +> Consult the [path-to-regexp v8 docs](https://github.com/pillarjs/path-to-regexp) and the [Express 5 migration guide](https://expressjs.com/en/guide/migrating-5.html#path-syntax) for more details on the new path syntax. + +### Related Pull Request +- [#9942](https://github.com/parse-community/parse-server/pull/9942) diff --git a/package-lock.json b/package-lock.json index ca04cedc..3e6cd604 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "mustache": "4.2.0", "otpauth": "9.4.0", "parse": "8.0.0", - "path-to-regexp": "6.3.0", + "path-to-regexp": "8.3.0", "pg-monitor": "3.0.0", "pg-promise": "12.2.0", "pluralize": "8.0.0", @@ -12474,13 +12474,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", - "optional": true, - "peer": true - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -18494,9 +18487,14 @@ } }, "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/path-type": { "version": "4.0.0", @@ -19796,14 +19794,6 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "engines": { - "node": ">=16" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -20682,32 +20672,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true, - "peer": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "optional": true, - "peer": true, - "dependencies": { - "ip": "^2.0.0", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.13.0", - "npm": ">= 3.0.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -31334,13 +31298,6 @@ "p-is-promise": "^3.0.0" } }, - "ip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", - "optional": true, - "peer": true - }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -35522,9 +35479,9 @@ } }, "path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" }, "path-type": { "version": "4.0.0", @@ -36453,11 +36410,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" - }, - "path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==" } } }, @@ -37046,24 +36998,6 @@ } } }, - "smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "optional": true, - "peer": true - }, - "socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", - "optional": true, - "peer": true, - "requires": { - "ip": "^2.0.0", - "smart-buffer": "^4.2.0" - } - }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index dfa1184d..96c784d8 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "mongodb": "6.20.0", "mustache": "4.2.0", "otpauth": "9.4.0", + "path-to-regexp": "8.3.0", "parse": "8.0.0", - "path-to-regexp": "6.3.0", "pg-monitor": "3.0.0", "pg-promise": "12.2.0", "pluralize": "8.0.0", diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 3c578107..429b7aae 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -5,7 +5,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -26,7 +26,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -45,7 +45,7 @@ describe('rate limit', () => { Parse.Cloud.define('test', () => 'Abc'); await reconfigureServer({ rateLimit: { - requestPath: '*', + requestPath: '/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -83,7 +83,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -102,7 +102,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, includeMasterKey: true, @@ -122,7 +122,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/classes/*', + requestPath: '/classes/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -141,7 +141,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/classes/*', + requestPath: '/classes/*path', requestTimeWindow: 10000, requestCount: 1, requestMethods: 'POST', @@ -240,7 +240,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/classes/Test/*', + requestPath: '/classes/Test/*path', requestTimeWindow: 10000, requestCount: 1, requestMethods: 'DELETE', @@ -294,7 +294,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 100, errorResponseMessage: 'Too many requests', @@ -320,7 +320,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -340,7 +340,7 @@ describe('rate limit', () => { it('can use global zone', async () => { await reconfigureServer({ rateLimit: { - requestPath: '*', + requestPath: '*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -373,7 +373,7 @@ describe('rate limit', () => { }); fakeRes.json = jasmine.createSpy('json').and.callFake(resolvingPromise); middlewares.handleParseHeaders(fakeReq, fakeRes, () => { - throw 'Should not call next'; + throw new Error('Should not call next'); }); await promise; expect(fakeRes.status).toHaveBeenCalledWith(429); @@ -386,7 +386,7 @@ describe('rate limit', () => { it('can use session zone', async () => { await reconfigureServer({ rateLimit: { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -407,7 +407,7 @@ describe('rate limit', () => { it('can use user zone', async () => { await reconfigureServer({ rateLimit: { - requestPath: '/functions/*', + requestPath: '/functions/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', @@ -494,7 +494,7 @@ describe('rate limit', () => { await reconfigureServer({ rateLimit: [ { - requestPath: '/classes/*', + requestPath: '/classes/*path', requestTimeWindow: 10000, requestCount: 1, errorResponseMessage: 'Too many requests', diff --git a/src/Config.js b/src/Config.js index 241edf97..879aebd7 100644 --- a/src/Config.js +++ b/src/Config.js @@ -3,6 +3,7 @@ // mount is the URL for the root of the API; includes http, domain, etc. import { isBoolean, isString } from 'lodash'; +import { pathToRegexp } from 'path-to-regexp'; import net from 'net'; import AppCache from './cache'; import DatabaseController from './Controllers/DatabaseController'; @@ -687,6 +688,14 @@ export class Config { if (typeof option.requestPath !== 'string') { throw `rateLimit.requestPath must be a string`; } + + // Validate that the path is valid path-to-regexp syntax + try { + pathToRegexp(option.requestPath); + } catch (error) { + throw `rateLimit.requestPath "${option.requestPath}" is not valid: ${error.message}`; + } + if (option.requestTimeWindow == null) { throw `rateLimit.requestTimeWindow must be defined`; } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 66c1d8bc..8fa173d6 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -686,7 +686,7 @@ module.exports.RateLimitOptions = { requestPath: { env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_PATH', help: - 'The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html', + 'The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax.', required: true, }, requestTimeWindow: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 9569239e..60bbf022 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -121,7 +121,7 @@ * @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. - * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html + * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. * @property {String} zone The type of rate limit to apply. The following types are supported:Default is `ip`. */ diff --git a/src/Options/index.js b/src/Options/index.js index cdeb7cd8..9ff66b30 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -353,7 +353,7 @@ export interface ParseServerOptions { } export interface RateLimitOptions { - /* The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html */ + /* The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings or string patterns following path-to-regexp v8 syntax. */ requestPath: string; /* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */ requestTimeWindow: ?number; diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index a057f8b3..3fc38c3a 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -82,12 +82,12 @@ const getRoute = parseClass => { '@Config' : 'config', }[parseClass] || 'classes'; if (parseClass === '@File') { - return `/${route}/:id?(.*)`; + return `/${route}{/*id}`; } if (parseClass === '@Config') { return `/${route}`; } - return `/${route}/${parseClass}/:id?(.*)`; + return `/${route}/${parseClass}{/*id}`; }; /** @namespace * @name Parse diff --git a/src/middlewares.js b/src/middlewares.js index 2fedce8f..e01b3807 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -322,7 +322,7 @@ const handleRateLimit = async (req, res, next) => { try { await Promise.all( rateLimits.map(async limit => { - const pathExp = new RegExp(limit.path); + const pathExp = limit.path.regexp || limit.path; if (pathExp.test(req.url)) { await limit.handler(req, res, err => { if (err) { @@ -560,12 +560,8 @@ export const addRateLimit = (route, config, cloud) => { }, }); } - let transformPath = route.requestPath.split('/*').join('/(.*)'); - if (transformPath === '*') { - transformPath = '(.*)'; - } config.rateLimits.push({ - path: pathToRegexp(transformPath), + path: pathToRegexp(route.requestPath), handler: rateLimit({ windowMs: route.requestTimeWindow, max: route.requestCount,