diff --git a/frontend/src/public/forms.tsx b/frontend/src/public/forms.tsx index 7a1a54e8e..5ddc04f30 100644 --- a/frontend/src/public/forms.tsx +++ b/frontend/src/public/forms.tsx @@ -4,12 +4,13 @@ import { Redirect, Route, Router, Switch } from 'react-router-dom'; import { IntlProvider } from 'react-intl'; import { Provider } from 'react-redux'; +import { createBrowserHistory } from 'history'; import { store } from './redux/store'; -import { history } from './utils/history'; import { initSentry } from './utils/initSentry'; import { getPublicFormConfig } from './utils/getConfig'; +import { getFormsBasename } from './utils/identifyAppPart/constants'; import { EPublicFormRoutes } from './constants/routes'; import { SharedPublicForm, EmbeddedPublicForm } from './components/PublicFormsApp'; import { AppLocale } from './lang'; @@ -25,13 +26,17 @@ initSentry(getPublicFormConfig, 'forms'); const { config: { mainPage }, } = getPublicFormConfig(); + +const formsHistory = createBrowserHistory({ + basename: getFormsBasename(window.location.pathname) || '/', +}); const currentAppLocale = AppLocale[defaultLocale]; ReactDOM.render( }> - + { + describe('path-based mode — returns FORMS_PATH_PREFIX', () => { + it('should return "/forms" for /forms/{token}', () => { + expect(getFormsBasename('/forms/abc123')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for /forms/{token}/ with trailing slash', () => { + expect(getFormsBasename('/forms/abc123/')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for exact /forms prefix (no token)', () => { + expect(getFormsBasename('/forms')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for /forms/ with trailing slash only', () => { + expect(getFormsBasename('/forms/')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for nested path /forms/embed/{token}', () => { + expect(getFormsBasename('/forms/embed/abc123')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for deep nested path /forms/a/b/c', () => { + expect(getFormsBasename('/forms/a/b/c')).toBe(FORMS_PATH_PREFIX); + }); + + it('should return "/forms" for UUID token /forms/550e8400-e29b-41d4-a716-446655440000', () => { + expect(getFormsBasename('/forms/550e8400-e29b-41d4-a716-446655440000')).toBe(FORMS_PATH_PREFIX); + }); + }); + + describe('subdomain mode — returns undefined', () => { + it('should return undefined for root path /', () => { + expect(getFormsBasename('/')).toBeUndefined(); + }); + + it('should return undefined for /{token} (subdomain mode)', () => { + expect(getFormsBasename('/abc123')).toBeUndefined(); + }); + + it('should return undefined for /{token}/ with trailing slash', () => { + expect(getFormsBasename('/abc123/')).toBeUndefined(); + }); + + it('should return undefined for /embed/{token} (subdomain embed)', () => { + expect(getFormsBasename('/embed/abc123')).toBeUndefined(); + }); + + it('should return undefined for /error/ path', () => { + expect(getFormsBasename('/error/')).toBeUndefined(); + }); + }); + + describe('boundary cases — paths starting with "form" but not "/forms"', () => { + it('should return undefined for /formsome (no slash after "form")', () => { + expect(getFormsBasename('/formsome')).toBeUndefined(); + }); + + it('should return undefined for /formation/abc', () => { + expect(getFormsBasename('/formation/abc')).toBeUndefined(); + }); + + it('should return undefined for /form (shorter than /forms)', () => { + expect(getFormsBasename('/form')).toBeUndefined(); + }); + + it('should return undefined for /formset/abc', () => { + expect(getFormsBasename('/formset/abc')).toBeUndefined(); + }); + + it('should return undefined for empty path', () => { + expect(getFormsBasename('')).toBeUndefined(); + }); + }); + + describe('security — prevents path traversal or injection', () => { + it('should return undefined for /Forms/abc (case-sensitive)', () => { + expect(getFormsBasename('/Forms/abc')).toBeUndefined(); + }); + + it('should return undefined for /FORMS/abc (uppercase)', () => { + expect(getFormsBasename('/FORMS/abc')).toBeUndefined(); + }); + + it('should return "/forms" for /forms/../../etc (still starts with /forms/)', () => { + // The function only checks prefix, path traversal is handled by Express + expect(getFormsBasename('/forms/../../etc')).toBe(FORMS_PATH_PREFIX); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts new file mode 100644 index 000000000..5371d0571 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts @@ -0,0 +1,136 @@ +// +import { identifyAppPartOnClient } from '../identifyAppPartOnClient'; +import { EAppPart } from '../types'; +import { FORMS_PATH_PREFIX } from '../constants'; + +jest.mock('../../getConfig', () => ({ + getBrowserConfig: jest.fn(), +})); + +jest.mock('../../history', () => ({ + history: { location: { pathname: '/' }, push: jest.fn(), listen: jest.fn() }, +})); + +import { getBrowserConfig } from '../../getConfig'; +import { history } from '../../history'; + +const setWindowLocation = (overrides: Record = {}) => { + Object.defineProperty(window, 'location', { + value: { + pathname: '/', + hostname: 'localhost', + ...overrides, + }, + writable: true, + configurable: true, + }); +}; + +describe('identifyAppPartOnClient', () => { + const mockGetBrowserConfig = getBrowserConfig as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + setWindowLocation(); + (history as any).location.pathname = '/'; + }); + + describe('forms detection', () => { + it('returns PublicFormApp for path-based forms (/forms/*)', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when pathname equals /forms', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + setWindowLocation({ pathname: FORMS_PATH_PREFIX }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp for subdomain forms', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when both path and subdomain match', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ + pathname: `${FORMS_PATH_PREFIX}/token`, + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('guest task', () => { + beforeEach(() => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + }); + + it('returns GuestTaskApp when history pathname contains /guest-task/', () => { + (history as any).location.pathname = '/guest-task/some-token'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.GuestTaskApp); + }); + }); + + describe('main app fallback', () => { + beforeEach(() => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + }); + + it('returns MainApp for regular paths', () => { + setWindowLocation({ pathname: '/dashboard' }); + (history as any).location.pathname = '/dashboard'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + + it('returns MainApp when formSubdomain is empty string', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' }); + (history as any).location.pathname = '/dashboard'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + }); + + it('does NOT match /formsome as forms path (boundary check)', () => { + setWindowLocation({ pathname: '/formsome/token' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + + it('does NOT match /form as forms path', () => { + setWindowLocation({ pathname: '/form/token' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + + it('handles formSubdomain=undefined without crashing', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: undefined } }); + setWindowLocation({ pathname: '/dashboard' }); + (history as any).location.pathname = '/dashboard'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + + it('partial hostname match — exact comparison rejects substrings', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'reform.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts new file mode 100644 index 000000000..c678f5d08 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts @@ -0,0 +1,139 @@ +// +import { identifyAppPartOnServer } from '../identifyAppPartOnServer'; +import { EAppPart } from '../types'; +import { FORMS_PATH_PREFIX } from '../constants'; + +jest.mock('../../getConfig', () => ({ + getConfig: jest.fn(), +})); + +import { getConfig } from '../../getConfig'; + +const makeRequest = (overrides: Record = {}) => ({ + baseUrl: '', + path: '/', + hostname: 'localhost', + url: '/', + headers: {}, + ...overrides, +} as any); + +describe('identifyAppPartOnServer', () => { + const mockGetConfig = getConfig as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('forms detection', () => { + it('returns PublicFormApp when baseUrl is FORMS_PATH_PREFIX', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ baseUrl: FORMS_PATH_PREFIX, path: '/abc123' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when path starts with FORMS_PATH_PREFIX/', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ path: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp for subdomain forms', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when both path and subdomain match', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/token', + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ hostname: 'localhost', path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('guest task', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns GuestTaskApp when url contains /guest-task/', () => { + const req = makeRequest({ url: '/guest-task/some-token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.GuestTaskApp); + }); + }); + + describe('priority order', () => { + it('forms takes priority over guest-task', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/guest-task/token', + url: '/guest-task/token', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('main app fallback', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns MainApp for regular paths', () => { + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('does NOT match /formsome as forms path (boundary check)', () => { + const req = makeRequest({ path: '/formsome/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('does NOT match /form as forms path', () => { + const req = makeRequest({ path: '/form/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('handles formSubdomain=undefined without crashing', () => { + mockGetConfig.mockReturnValue({ formSubdomain: undefined }); + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('partial hostname match — exact comparison rejects substrings', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'reform.example.com' }); + + // hostname === formSubdomain ensures no false positives + // "reform.example.com" !== "form.example.com" + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts new file mode 100644 index 000000000..e9b86bc2c --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts @@ -0,0 +1,60 @@ +// +import { isFormPath, FORMS_PATH_PREFIX } from '../constants'; + +describe('isFormPath', () => { + describe('path-based detection', () => { + it('returns true when pathname starts with /forms/', () => { + expect(isFormPath('localhost', '/forms/abc123', '')).toBe(true); + }); + + it('returns true when pathname equals /forms', () => { + expect(isFormPath('localhost', FORMS_PATH_PREFIX, '')).toBe(true); + }); + + it('returns true for nested path /forms/embed/abc', () => { + expect(isFormPath('localhost', '/forms/embed/abc', '')).toBe(true); + }); + + it('returns false for /formsome (boundary check)', () => { + expect(isFormPath('localhost', '/formsome', '')).toBe(false); + }); + + it('returns false for /form (shorter than /forms)', () => { + expect(isFormPath('localhost', '/form', '')).toBe(false); + }); + }); + + describe('subdomain detection', () => { + it('returns true when hostname includes formSubdomain', () => { + expect(isFormPath('form.example.com', '/', 'form.example.com')).toBe(true); + }); + + it('returns false when formSubdomain is empty string', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + + it('returns false when formSubdomain is undefined', () => { + expect(isFormPath('localhost', '/', undefined)).toBe(false); + }); + + it('returns false for partial hostname match (exact comparison)', () => { + expect(isFormPath('reform.example.com', '/', 'form.example.com')).toBe(false); + }); + }); + + describe('combined — both match', () => { + it('returns true when both path and subdomain match', () => { + expect(isFormPath('form.example.com', '/forms/token', 'form.example.com')).toBe(true); + }); + }); + + describe('fallback — neither match', () => { + it('returns false for regular path with no subdomain', () => { + expect(isFormPath('localhost', '/dashboard', '')).toBe(false); + }); + + it('returns false for root path with no subdomain', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/constants.ts b/frontend/src/public/utils/identifyAppPart/constants.ts index 9975b37cb..62d3a50b2 100644 --- a/frontend/src/public/utils/identifyAppPart/constants.ts +++ b/frontend/src/public/utils/identifyAppPart/constants.ts @@ -1 +1,33 @@ export const GUEST_URLS = ['/guest-task/']; +export const FORMS_PATH_PREFIX = '/forms'; + +/** + * Checks whether the current request targets the public forms app. + * Combines both detection strategies: + * - Path-based: domain.com/forms/* + * - Subdomain: form.domain.com/* + */ +export function isFormPath( + hostname: string, + pathname: string, + formSubdomain: string | undefined, +): boolean { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + const isSubdomain = !!formSubdomain && hostname === formSubdomain; + + return isPathBased || isSubdomain; +} + +/** + * Returns the basename for React Router in the forms app. + * - Path-based mode (/forms/{token}): returns '/forms' so Router strips the prefix + * - Subdomain mode (forms.domain.com/{token}): returns undefined (no prefix to strip) + */ +export function getFormsBasename(pathname: string): string | undefined { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + return isPathBased ? FORMS_PATH_PREFIX : undefined; +} diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts index 0a182d311..d9e86408e 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts @@ -1,17 +1,20 @@ -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, isFormPath } from './constants'; import { EAppPart } from './types'; import { getBrowserConfig } from '../getConfig'; import { history } from '../history'; export const identifyAppPartOnClient = (): EAppPart => { + const { config: { formSubdomain } } = getBrowserConfig(); + const identifyAppPartMap = [ { - check: () => { - const { config: { formSubdomain } } = getBrowserConfig(); - - return window.location.hostname.includes(formSubdomain); - }, + // Forms: path-based (domain.com/forms/*) or subdomain (form.domain.com/*) + check: () => isFormPath( + window.location.hostname, + window.location.pathname, + formSubdomain, + ), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts index 279ed37f4..b8d319b18 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts @@ -1,16 +1,18 @@ import { Request } from 'express'; -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, FORMS_PATH_PREFIX, isFormPath } from './constants'; import { EAppPart } from './types'; import { getConfig } from '../getConfig'; export const identifyAppPartOnServer = (req: Request): EAppPart => { + const { formSubdomain } = getConfig(); + const identifyAppPartMap = [ { - check: () => { - const { formSubdomain } = getConfig(); - return req.hostname.includes(formSubdomain); - }, + // Forms: path-based (domain.com/forms/*), subdomain (form.domain.com/*), + // or Express-mounted sub-app at /forms + check: () => req.baseUrl === FORMS_PATH_PREFIX + || isFormPath(req.hostname, req.path, formSubdomain), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/server/handlers/__tests__/mainHandler.test.ts b/frontend/src/server/handlers/__tests__/mainHandler.test.ts index d6924537c..d16fa5397 100644 --- a/frontend/src/server/handlers/__tests__/mainHandler.test.ts +++ b/frontend/src/server/handlers/__tests__/mainHandler.test.ts @@ -22,6 +22,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -31,6 +33,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; const env = process.env.MCS_RUN_ENV || 'local'; jest.spyOn(serverApi, 'get').mockResolvedValueOnce({}); @@ -50,6 +53,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -59,6 +64,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; await mainHandler(req as unknown as Request, res as unknown as Response); diff --git a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts index 277e6a382..1e3433f5e 100644 --- a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts +++ b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts @@ -15,6 +15,8 @@ const req: any = { header: jest.fn(), get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, diff --git a/frontend/src/server/server.ts b/frontend/src/server/server.ts index cdbd80a85..f4e93467f 100644 --- a/frontend/src/server/server.ts +++ b/frontend/src/server/server.ts @@ -10,6 +10,7 @@ import { mainHandler, oAuthHandler, apiProxy } from './handlers'; import { authMiddleware, verificateAccountMiddleware, forwardForSubdomain } from './middleware'; import { getConfig, serverConfigToBrowser } from '../public/utils/getConfig'; import { ERoutes } from '../public/constants/routes'; +import { FORMS_PATH_PREFIX } from '../public/utils/identifyAppPart/constants'; import { setPublicAuthCookie } from './utils/cookie'; import { getUserPublic } from './middleware/utils/getUserPublic'; import { mapToCamelCase } from '../public/utils/mappers'; @@ -95,7 +96,14 @@ export function initServer() { return null; }); - app.use(forwardForSubdomain([formSubdomain], formsRouter)); + // Path-based forms: domain.com/forms/* (always available) + app.use(FORMS_PATH_PREFIX, formsRouter); + + // Subdomain forms: form.domain.com/* (if FORM_DOMAIN is set) + if (formSubdomain) { + app.use(forwardForSubdomain([formSubdomain], formsRouter)); + } + app.get(ERoutes.AccountVerificationLink, verificateAccountMiddleware); app.get(ERoutes.OAuthGoogle, oAuthHandler(urls.getGoogleAuthUri, urls.getGoogleAuthToken)); app.get(ERoutes.OAuthMicrosoft, oAuthHandler(urls.getMicrosoftAuthUri, urls.getMicrosoftAuthToken));