Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ const verification = createRoute(
res.log.error({ err }, '[AUTH][PLACEHOLDER_USER][ERROR] Error destroying session');
}
const searchParams = new URLSearchParams({ error: 'InvalidRegistration' });
sendJson(res, { error: false, redirect: `/auth/login/?${searchParams.toString()}` });
sendJson(res, { error: false, redirect: `/auth/login?${searchParams.toString()}` });
});
return;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/canvas.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const callbackHandler = createRoute(routeDefinition.callbackHandler.validators,
if (returnParams.error) {
res.status(401);
}
res.redirect(`/canvas-auth/?${new URLSearchParams(returnParams).toString().replaceAll('+', '%20')}`);
res.redirect(`/canvas-auth?${new URLSearchParams(returnParams).toString().replaceAll('+', '%20')}`);
});

const appHandler = createRoute(routeDefinition.appHandler.validators, async ({ body = {}, query }, _req, res) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/desktop-app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ const initAuthMiddleware = createRoute(routeDefinition.initAuthMiddleware.valida
// cookies, so the frontend must pass returnUrl forward to /api/auth/sso/start where it can
// be threaded into SAML RelayState (and the OIDC returnUrl cookie).
setCookie(redirectUrlCookie.name, desktopReturnUrl, redirectUrlCookie.options);
redirect(res, `/auth/login/?returnUrl=${encodeURIComponent(desktopReturnUrl)}`);
redirect(res, `/auth/login?returnUrl=${encodeURIComponent(desktopReturnUrl)}`);
return;
}
next();
Expand Down
8 changes: 4 additions & 4 deletions apps/api/src/app/controllers/oauth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,15 @@ const salesforceOauthCallback = createRoute(
: 'There was an error authenticating with Salesforce.';
res.log.warn({ ...queryParams, requestId: res.locals.requestId, queryParams }, '[OAUTH][ERROR] %s', queryParams.error);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
return res.redirect(`/oauth-link?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
} else if (!orgAuth) {
returnParams.error = 'Authentication Error';
returnParams.message = queryParams.error_description
? (queryParams.error_description as string)
: 'There was an error authenticating with Salesforce.';
res.log.warn({ ...queryParams, requestId: res.locals.requestId, queryParams }, '[OAUTH][ERROR] Missing orgAuth from session');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
return res.redirect(`/oauth-link?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
}

const { code_verifier, nonce, state, loginUrl, orgGroupId } = orgAuth;
Expand Down Expand Up @@ -146,7 +146,7 @@ const salesforceOauthCallback = createRoute(

returnParams.data = JSON.stringify(salesforceOrg);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
return res.redirect(`/oauth-link?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
} catch (ex) {
let errorLogObj: Record<string, any> = { err: ex };

Expand All @@ -170,7 +170,7 @@ const salesforceOauthCallback = createRoute(
res.log.warn(errorLogObj, '[OAUTH][ERROR]');

// eslint-disable-next-line @typescript-eslint/no-explicit-any
return res.redirect(`/oauth-link/?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
return res.redirect(`/oauth-link?${new URLSearchParams(returnParams as any).toString().replaceAll('+', '%20')}`);
}
},
);
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/controllers/web-extension.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ const initAuthMiddleware = createRoute(routeDefinition.initAuthMiddleware.valida
if (!req.session.user) {
const { redirectUrl: redirectUrlCookie } = getCookieConfig(ENV.USE_SECURE_COOKIES);
setCookie(redirectUrlCookie.name, `${ENV.JETSTREAM_SERVER_URL}/web-extension/auth`, redirectUrlCookie.options);
redirect(res, '/auth/login/');
redirect(res, '/auth/login');
return;
}
next();
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/app/routes/redirect.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ routes.get('/', (req, res, next) => {
);
}

res.redirect(`${ENV.JETSTREAM_SERVER_URL}/auth/login/?${params.toString()}`);
res.redirect(`${ENV.JETSTREAM_SERVER_URL}/auth/login?${params.toString()}`);
});

export default routes;
26 changes: 26 additions & 0 deletions apps/api/src/app/routes/route.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import multer from 'multer';
import { randomBytes } from 'node:crypto';
import os from 'node:os';
import { posix as pathPosix } from 'node:path';
import pino from 'pino';
import { v4 as uuid } from 'uuid';
import * as salesforceOrgsDb from '../db/salesforce-org.db';
Expand Down Expand Up @@ -79,6 +80,31 @@
next(error);
}

/**
* Strips trailing slashes from GET/HEAD requests via 301 redirect — needed because
* Next.js export with trailingSlash:false emits foo.html (not foo/index.html), and
* Express static won't auto-redirect /foo/ → /foo. Preserves old bookmarks and
* indexed URLs.
*
* Collapses runs of slashes before stripping the trailing one — otherwise a request
* like `//evil.com/` would yield `Location: //evil.com`, which browsers follow as a
* protocol-relative URL to evil.com (open redirect).
*/
export function stripTrailingSlashRedirect(req: express.Request, res: express.Response, next: express.NextFunction) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return next();
}
if (req.path === '/' || !req.path.endsWith('/')) {
return next();
}
const newPath = pathPosix.normalize(req.path.replace(/\/+/g, '/')).replace(/\/$/, '');
if (!newPath.startsWith('/') || newPath.startsWith('//')) {
return next();
}
const query = req.url.slice(req.path.length);
res.redirect(301, newPath + query);

Check warning

Code scanning / CodeQL

Server-side URL redirect Medium

Untrusted URL redirection depends on a
user-provided value
.
Comment on lines +97 to +105
}

export function destroySessionIfPendingVerificationIsExpired(req: express.Request, _: express.Response, next: express.NextFunction) {
if (req.session?.pendingVerification?.length) {
const { exp } = req.session.pendingVerification[0];
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/utils/response.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res:
} else if (err instanceof AuthError) {
deferredErrorBody.errorType = err.type;
deferredErrorBody.logout = true;
deferredErrorBody.logoutUrl = `${ENV.JETSTREAM_SERVER_URL}/auth/login/?${new URLSearchParams({ error: err.type }).toString()}`;
deferredErrorBody.logoutUrl = `${ENV.JETSTREAM_SERVER_URL}/auth/login?${new URLSearchParams({ error: err.type }).toString()}`;
}

writeDeferredResponse(res, deferredErrorBody);
Expand Down Expand Up @@ -298,7 +298,7 @@ export async function uncaughtErrorHandler(err: any, req: express.Request, res:
});
}
const params = new URLSearchParams({ error: err.type }).toString();
return res.redirect(`${ENV.JETSTREAM_SERVER_URL}/auth/login/?${params}`);
return res.redirect(`${ENV.JETSTREAM_SERVER_URL}/auth/login?${params}`);
} else if (err instanceof UserFacingError) {
// Attempt to use response code from 3rd party request if we have it available
const statusCode = err.apiRequestError?.status || status || 400;
Expand Down
20 changes: 9 additions & 11 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
redirectIfPendingVerificationMiddleware,
setCacheControlForApiRoutes,
setPermissionPolicy,
stripTrailingSlashRedirect,
} from './app/routes/route.middleware';
import { healthCheck, uncaughtErrorHandler } from './app/utils/response.handlers';
import { buildCspDirectives, buildHstsConfig } from './app/utils/security-headers';
Expand Down Expand Up @@ -302,21 +303,18 @@ if (ENV.NODE_ENV === 'production' && !ENV.CI && cluster.isPrimary) {
app.use('/.well-known', express.static(join(__dirname, './assets/.well-known')));
app.use('/assets', express.static(join(__dirname, './assets'), { maxAge: '1m' }));
app.use('/fonts', express.static(join(__dirname, './assets/fonts')));
app.use(express.static(join(__dirname, '../landing')));
// extensions: ['html'] lets Express serve foo.html for a /foo request — needed because
// Next.js export with trailingSlash:false emits flat .html files (not directory index.html).
app.use(stripTrailingSlashRedirect, express.static(join(__dirname, '../landing'), { extensions: ['html'] }));

// Load the landing site's 404 page so uncaughtErrorHandler can serve it inline
// with a real 404 status (instead of redirecting to /404/, which logged as 302
// and masked which URLs were actually missing). Next.js export emits either
// `404/index.html` (trailingSlash) or `404.html` depending on build config.
// with a real 404 status (instead of redirecting to /404, which logged as 302
// and masked which URLs were actually missing).
let notFoundHtml: string | null = null;
try {
notFoundHtml = readFileSync(join(__dirname, '../landing/404/index.html'), 'utf8');
} catch {
try {
notFoundHtml = readFileSync(join(__dirname, '../landing/404.html'), 'utf8');
} catch (error) {
logger.error({ err: error }, '[404] Failed to read landing 404 page — 404 responses will fall back to plain text');
}
notFoundHtml = readFileSync(join(__dirname, '../landing/404.html'), 'utf8');
} catch (error) {
logger.error({ err: error }, '[404] Failed to read landing 404 page — 404 responses will fall back to plain text');
}
app.locals.notFoundHtml = notFoundHtml;

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/getting-started/desktop-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ Some features are currently not available in the desktop application. We are wor

The Desktop Application provides the full power of Jetstream outside of your web-browser. The key benefit is that none of your Salesforce credentials or data is processed through the Jetstream server.

Visit the [download page](https://getjetstream.app/desktop-app/) to get the latest version of the desktop application for your operating system.
Visit the [download page](https://getjetstream.app/desktop-app) to get the latest version of the desktop application for your operating system.
4 changes: 2 additions & 2 deletions apps/docs/docs/getting-started/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ If you have questions or want to talk with a human, you can reach support by ema

:::tip

If you haven't created a Jetstream account, you can [sign up here](https://getjetstream.app/auth/signup/).
If you haven't created a Jetstream account, you can [sign up here](https://getjetstream.app/auth/signup).

:::

:::tip

<JetstreamProLogo width="200px" />
To get the most out of Jetstream, sign up for a paid plan. [View features included in Pro here](https://getjetstream.app/pricing/).
To get the most out of Jetstream, sign up for a paid plan. [View features included in Pro here](https://getjetstream.app/pricing).

:::

Expand Down
8 changes: 4 additions & 4 deletions apps/docs/docs/getting-started/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ Jetstream is designed with security and privacy in mind. We take the protection

## Additional Resources

- [Data Processing Agreement](https://getjetstream.app/dpa/)
- [Data Sub-Processors](https://getjetstream.app/subprocessors/)
- [Security and Privacy](https://getjetstream.app/privacy/)
- [Terms of Service](https://getjetstream.app/terms-of-service/)
- [Data Processing Agreement](https://getjetstream.app/dpa)
- [Data Sub-Processors](https://getjetstream.app/subprocessors)
- [Security and Privacy](https://getjetstream.app/privacy)
- [Terms of Service](https://getjetstream.app/terms-of-service)

## Web Application Security

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/docs/team-management/team-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ slug: /team-management

:::info

This feature is available on our Team and Enterprise plans. [Learn more about our plans and pricing](https://getjetstream.app/pricing/).
This feature is available on our Team and Enterprise plans. [Learn more about our plans and pricing](https://getjetstream.app/pricing).

:::

Expand Down
6 changes: 3 additions & 3 deletions apps/docs/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,15 @@ const config: Config = {
title: 'Legal',
items: [
{
href: 'https://getjetstream.app/terms-of-service/',
href: 'https://getjetstream.app/terms-of-service',
label: 'Terms of Service',
},
{
href: 'https://getjetstream.app/subprocessors/',
href: 'https://getjetstream.app/subprocessors',
label: 'Data Sub-Processors',
},
{
href: 'https://getjetstream.app/privacy/',
href: 'https://getjetstream.app/privacy',
label: 'Privacy Policy',
},
],
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/shared-components/RequiresProPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const RequiresProPlan = () => {
<JetstreamProLogo width="250px" />
<p>
This feature is only available on the Pro plan.{' '}
<a href="https://getjetstream.app/pricing/" target="_blank" rel="noopener noreferrer">
<a href="https://getjetstream.app/pricing" target="_blank" rel="noopener noreferrer">
Upgrade
</a>
.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ test.describe('Desktop / Web-Extension Authentication', () => {
const deviceId = uuid();
const token = uuid();

await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`);
await page.goto(`/desktop-app/auth?deviceId=${deviceId}&token=${token}`);
await expect(page.getByText('You are successfully authenticated, you can close this tab.')).toBeVisible();
});

test('Desktop Authentication - Missing query params', async ({ page, teamCreationUtils1User }) => {
await page.goto(`/desktop-app/auth/`);
await page.goto(`/desktop-app/auth`);
await expect(page.getByText('Error communicating with desktop application, is the application open?')).toBeVisible();
});

Expand Down Expand Up @@ -80,7 +80,7 @@ test.describe('Desktop / Web-Extension Authentication', () => {

// TODO: we don't have a way to test this currently since the extension is not installed
test('Web Extension Authentication - Extension not installed', async ({ page, teamCreationUtils1User }) => {
await page.goto(`/web-extension/auth/`);
await page.goto(`/web-extension/auth`);
await expect(page.getByText('Authentication in progress...')).toBeVisible();
});

Expand Down Expand Up @@ -354,13 +354,13 @@ test.describe('Desktop / Web-Extension Authentication - Not Logged In', () => {
const deviceId = uuid();
const token = uuid();

await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`);
expect(page.url()).toContain('/auth/login/');
await page.goto(`/desktop-app/auth?deviceId=${deviceId}&token=${token}`);
expect(page.url()).toContain('/auth/login');
});

test('Web Extension - Extension not installed', async ({ page }) => {
await page.goto(`/web-extension/auth/`);
expect(page.url()).toContain('/auth/login/');
await page.goto(`/web-extension/auth`);
expect(page.url()).toContain('/auth/login');
});
});

Expand All @@ -379,18 +379,18 @@ test.describe('Desktop / Web-Extension Authentication - No Access', () => {
const deviceId = uuid();
const token = uuid();

await page.goto(`/desktop-app/auth/?deviceId=${deviceId}&token=${token}`);
await page.goto(`/desktop-app/auth?deviceId=${deviceId}&token=${token}`);
await expect(page.getByText('You do not have a valid subscription to use the desktop application')).toBeVisible();
});

test('Desktop Authentication - Missing query params', async ({ page, newUser: _newUser }) => {
await page.goto(`/desktop-app/auth/`);
await page.goto(`/desktop-app/auth`);
await expect(page.getByText('Error communicating with desktop application, is the application open?')).toBeVisible();
});

// TODO: we don't have a way to test this currently since the extension is not installed
test('Web Extension - Extension not installed', async ({ page, newUser: _newUser }) => {
await page.goto(`/web-extension/auth/`);
await page.goto(`/web-extension/auth`);
await expect(page.getByText('Authentication in progress...')).toBeVisible();
});
});
10 changes: 5 additions & 5 deletions apps/jetstream-e2e/src/tests/authentication/team/team.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ test.describe('Team Dashboard', () => {
await member1Page.reload();
expect(member1Page.url()).toContain('/auth/login');

await authenticationPage.loginAndVerifyEmail(member1.user.email, member1.user.password, true, '**/auth/mfa-enroll/');
await authenticationPage.loginAndVerifyEmail(member1.user.email, member1.user.password, true, '**/auth/mfa-enroll**');
await expect(member1Page.getByRole('heading', { name: 'Scan the QR code with your' })).toBeVisible();

await authenticationPage.enrollInOtp(member1.user.email);
Expand Down Expand Up @@ -422,7 +422,7 @@ test.describe('Team Dashboard', () => {
expect(page.url()).toContain('/auth/login');

const authenticationPage = new AuthenticationPage(page);
await authenticationPage.loginAndVerifyEmail(user1.email, user1.password, true, '**/auth/mfa-enroll/');
await authenticationPage.loginAndVerifyEmail(user1.email, user1.password, true, '**/auth/mfa-enroll**');
await expect(page.getByRole('heading', { name: 'Scan the QR code with your' })).toBeVisible();

await authenticationPage.enrollInOtp(user1.email);
Expand Down Expand Up @@ -590,7 +590,7 @@ test.describe('Team Dashboard', () => {
await test.step('Navigating to app with abandoned enrollment redirects to enrollment page', async () => {
const newPage = await userContext.newPage();
await newPage.goto('/app');
expect(newPage.url()).toContain('/auth/mfa-enroll/');
expect(newPage.url()).toContain('/auth/mfa-enroll');
await expect(newPage.getByRole('heading', { name: 'Scan the QR code with your' })).toBeVisible();
await newPage.close();
});
Expand All @@ -601,7 +601,7 @@ test.describe('Team Dashboard', () => {
const auth = new AuthenticationPage(newPage);
await auth.fillOutLoginForm(user.email, user.password);
await delay(1000); // ensure session is initialized
await auth.verifyEmail(user.email, false, '**/auth/mfa-enroll/');
await auth.verifyEmail(user.email, false, '**/auth/mfa-enroll**');
await expect(newPage.getByRole('heading', { name: 'Scan the QR code with your' })).toBeVisible();
await newPage.getByRole('link', { name: 'Logout' }).click();
await context.close();
Expand All @@ -613,7 +613,7 @@ test.describe('Team Dashboard', () => {
const auth = new AuthenticationPage(newPage);
await auth.fillOutLoginForm(user.email, user.password);
await delay(1000); // ensure session is initialized
await auth.verifyEmail(user.email, false, '**/auth/mfa-enroll/');
await auth.verifyEmail(user.email, false, '**/auth/mfa-enroll**');
await expect(newPage.getByRole('heading', { name: 'Scan the QR code with your' })).toBeVisible();
await auth.enrollInOtp(user.email);
expect(newPage.url()).toContain('/app');
Expand Down
2 changes: 1 addition & 1 deletion apps/jetstream/src/app/components/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export const Settings = () => {
await deleteUserProfile(reason);
eraseCookies();

window.location.href = '/goodbye/';
window.location.href = '/goodbye';
} catch {
// error deleting everything from server
fireToast({
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const nextConfig = {
},
];
},
trailingSlash: true,
trailingSlash: false,
nx: {
// Set this to true if you would like to use SVGR
// See: https://github.com/gregberge/svgr
Expand Down
2 changes: 1 addition & 1 deletion apps/landing/pages/privacy/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default function Page() {
</p>
<p className="mb-2 pl-2">
Refer to our{' '}
<a className="underline" href="https://getjetstream.app/subprocessors/" target="_blank" rel="noreferrer">
<a className="underline" href="https://getjetstream.app/subprocessors" target="_blank" rel="noreferrer">
data sub-processors
</a>{' '}
for information about our vendors.
Expand Down
4 changes: 2 additions & 2 deletions apps/landing/utils/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export const ROUTES = {
},
AUTH: {
_root_path: '/auth/',
login: '/auth/login/',
signup: '/auth/signup/',
login: '/auth/login',
signup: '/auth/signup',
resetPassword: '/auth/password-reset',
resetPasswordVerify: '/auth/password-reset/verify',
verify: `/auth/verify`,
Expand Down
Loading
Loading