Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@ The following table lists all route groups covered by `routeAllowList` with exam
> [!NOTE]
> The GraphQL API is not covered by `routeAllowList`. `routeAllowList` gates the REST API per route, while every GraphQL operation is transported over a single endpoint with the operation, target class, and field set encoded in the request body — so per-route allow-list semantics do not compose with it.

> [!NOTE]
> The Pages API is not covered by `routeAllowList`. Its routes (default endpoint `apps`, configurable via `pages.pagesEndpoint`) serve the browser pages for email verification and password reset that Parse Server links to in the emails it sends to end users, so they must remain reachable without any Parse credentials and are not part of the client REST API. Their behavior is controlled by the email verification and password reset options (`verifyUserEmails`, `emailAdapter`) and the `pages` option.

## Email Verification and Password Reset

Verifying user email addresses and enabling password reset via email requires an email adapter. There are many email adapters provided and maintained by the community. The following is an example configuration with an example email adapter. See the [Parse Server Options][server-options] for more details and a full list of available options.
Expand Down
116 changes: 116 additions & 0 deletions spec/RouteAllowList.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,122 @@ describe('routeAllowList', () => {
});
});

describe('Pages exemption', () => {
// routeAllowList gates the client-facing REST API. The Pages API serves
// the browser pages for email verification and password reset that Parse
// Server links to in the emails it sends to end users, so those routes
// must remain reachable without Parse credentials. The Pages router is
// mounted ahead of the Parse request middleware chain and is not covered
// by the allow list; its behavior is governed by the email verification
// and password reset options instead.
const request = require('../lib/request');
const pagesConfig = () => ({
appName: 'exampleAppName',
publicServerURL: 'http://localhost:8378/1',
verifyUserEmails: true,
emailAdapter: {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
},
});
const expectForbidden = promise =>
expectAsync(promise).toBeRejectedWith(
jasmine.objectContaining({
data: jasmine.objectContaining({ code: Parse.Error.OPERATION_FORBIDDEN }),
})
);

it('reaches the email verification page when routeAllowList is empty array', async () => {
await reconfigureServer({ ...pagesConfig(), routeAllowList: [] });
await expectForbidden(request({ method: 'GET', url: 'http://localhost:8378/1/health' }));
const response = await request({
url: 'http://localhost:8378/1/apps/test/verify_email?token=invalidToken',
followRedirects: false,
});
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid verification link!');
});

it('reaches the password reset page when routeAllowList is empty array', async () => {
await reconfigureServer({ ...pagesConfig(), routeAllowList: [] });
await expectForbidden(
request({
method: 'POST',
url: 'http://localhost:8378/1/requestPasswordReset',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'user@example.com' }),
})
);
const response = await request({
url: 'http://localhost:8378/1/apps/test/request_password_reset?token=invalidToken',
followRedirects: false,
});
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid password reset link!');
});

it('reaches the resend verification email route when routeAllowList is empty array', async () => {
await reconfigureServer({ ...pagesConfig(), routeAllowList: [] });
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/apps/test/resend_verification_email',
body: 'username=unknownUser',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(303);
expect(response.headers.location).toContain('email_verification_send_success');
});

it('reaches static pages when routeAllowList is empty array', async () => {
await reconfigureServer({ ...pagesConfig(), routeAllowList: [] });
const response = await request({
url: 'http://localhost:8378/1/apps/password_reset.html',
followRedirects: false,
});
expect(response.status).toBe(200);
expect(response.text).toContain('Reset Your Password');
});

it('reaches Pages routes when routeAllowList contains only REST routes', async () => {
await reconfigureServer({ ...pagesConfig(), routeAllowList: ['classes/AllowedClass'] });
const response = await request({
url: 'http://localhost:8378/1/apps/test/verify_email?token=invalidToken',
followRedirects: false,
});
expect(response.status).toBe(200);
expect(response.text).toContain('Invalid verification link!');
});

it('completes email verification from the emailed link when routeAllowList is empty array', async () => {
const config = { ...pagesConfig(), routeAllowList: [] };
await reconfigureServer(config);
const sendVerificationEmail = spyOn(
config.emailAdapter,
'sendVerificationEmail'
).and.callThrough();
const user = new Parse.User();
user.setUsername('exampleUsername');
user.setPassword('examplePassword');
user.set('email', 'user@example.com');
await user.signUp(null, { useMasterKey: true });
await jasmine.timeout();
const link = sendVerificationEmail.calls.all()[0].args[0].link;
const response = await request({ url: link, followRedirects: false });
expect(response.status).toBe(200);
expect(response.text).toContain('Email verified!');
const verifiedUser = await new Parse.Query(Parse.User)
.equalTo('username', 'exampleUsername')
.first({ useMasterKey: true });
expect(verifiedUser.get('emailVerified')).toBe(true);
});
});

describe('batch sub-requests', () => {
// routeAllowList must be enforced per batch sub-request. The outer
// enforceRouteAllowList middleware runs only on the outer /batch URL,
Expand Down
2 changes: 1 addition & 1 deletion src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ module.exports.ParseServerOptions = {
},
routeAllowList: {
env: 'PARSE_SERVER_ROUTE_ALLOW_LIST',
help: '(Optional) Restricts external client access to a list of allowed REST API routes.<br><br>When this option is set, all external non-master-key REST API requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.<br><br><b>Note:</b> File routes and the GraphQL API are not covered by this option.',
help: '(Optional) Restricts external client access to a list of allowed REST API routes.<br><br>When this option is set, all external non-master-key REST API requests are denied by default. Only routes matching at least one of the configured regex patterns are allowed through. Internal calls from Cloud Code, Cloud Jobs, and triggers are not affected.<br><br>Each entry is a regex pattern string matched against the normalized route identifier (request path with mount prefix and leading slash stripped). Patterns are auto-anchored with `^` and `$` for full-match semantics.<br><br><b>Examples of normalized route identifiers:</b><ul><li>`classes/GameScore` (class CRUD)</li><li>`classes/GameScore/abc123` (object by ID)</li><li>`users` (user operations)</li><li>`login` (login endpoint)</li><li>`functions/sendEmail` (Cloud Function)</li><li>`jobs/cleanup` (Cloud Job)</li><li>`push` (push notifications)</li><li>`config` (client config)</li><li>`installations` (installations)</li></ul><b>Example patterns:</b><ul><li>`classes/ChatMessage` matches only `classes/ChatMessage`</li><li>`classes/Chat.*` matches `classes/ChatMessage`, `classes/ChatRoom`, etc.</li><li>`functions/.*` matches all Cloud Functions</li></ul>Setting an empty array `[]` blocks all external non-master-key REST API requests (full lockdown of REST API routes).<br><br>When setting the option via an environment variable, the notation is a comma-separated string, for example `"classes/ChatMessage,users,functions/.*"`.<br><br>Defaults to `undefined` which means the feature is inactive and all routes are accessible.<br><br><b>Note:</b> File routes, the Pages API and the GraphQL API are not covered by this option.',
action: parsers.arrayParser,
},
scheduledPush: {
Expand Down
2 changes: 1 addition & 1 deletion src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading