-
Notifications
You must be signed in to change notification settings - Fork 628
💄(frontend) add standalone 503 error page #2655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { expect, test } from '@playwright/test'; | ||
|
|
||
| test.describe('503', () => { | ||
| test('checks all the elements are visible', async ({ page }) => { | ||
| await page.goto('/503'); | ||
|
|
||
| await expect( | ||
| page.getByRole('heading', { level: 1, name: 'Error 503' }), | ||
| ).toBeVisible(); | ||
| await expect( | ||
| page.getByText('The server is temporarily overloaded or unavailable'), | ||
| ).toBeVisible(); | ||
| await expect( | ||
| page.getByRole('button', { name: 'Refresh page' }), | ||
| ).toBeVisible(); | ||
| await expect(page.getByTestId('header-logo-link')).toBeVisible(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import { Box, BoxButton, Icon, Text } from '@/components'; | ||
|
|
||
| import Error503Svg from '../assets/503.svg'; | ||
|
|
||
| const getSafeRefreshUrl = (target?: string): string | undefined => { | ||
| if (!target) { | ||
| return undefined; | ||
| } | ||
|
|
||
| if (typeof window === 'undefined') { | ||
| return target.startsWith('/') && !target.startsWith('//') | ||
| ? target | ||
| : undefined; | ||
| } | ||
|
|
||
| try { | ||
| const url = new URL(target, window.location.origin); | ||
| if (url.origin !== window.location.origin) { | ||
| return undefined; | ||
| } | ||
| return url.pathname + url.search + url.hash; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
node <<'NODE'
const base = 'https://docs.example';
const input = '/..//evil.example';
const parsed = new URL(input, base);
const candidate =
parsed.origin === base
? parsed.pathname + parsed.search + parsed.hash
: undefined;
const resolved = candidate === undefined ? undefined : new URL(candidate, base);
console.log({ input, parsed: parsed.href, candidate, resolved: resolved && resolved.href });
if (resolved && resolved.origin !== base) {
console.error('FAIL: refresh target resolves off-origin');
process.exit(1);
}
NODERepository: suitenumerique/docs Length of output: 347 Open Redirect (CWE-601): URL Redirection to Untrusted Site ('Open Redirect') Reachability: External · Exploitability: Trivial Reject protocol-relative paths after URL normalization.
Suggested fix- return url.pathname + url.search + url.hash;
+ const safeTarget = url.pathname + url.search + url.hash;
+ return safeTarget.startsWith('//') ? undefined : safeTarget;🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| } catch { | ||
| return undefined; | ||
| } | ||
| }; | ||
|
|
||
| type Error503Props = { | ||
| refreshTarget?: string; | ||
| }; | ||
|
|
||
| export const Error503 = ({ refreshTarget }: Error503Props) => { | ||
| const { t } = useTranslation(); | ||
| const safeTarget = getSafeRefreshUrl(refreshTarget); | ||
|
|
||
| return ( | ||
| <Box | ||
| $align="center" | ||
| $gap="xs" | ||
| $padding={{ horizontal: 'base' }} | ||
| className="--docs--error-503" | ||
| > | ||
| <Error503Svg aria-hidden="true" /> | ||
| <Box $align="center" $gap="3xs"> | ||
| <Text | ||
| as="h1" | ||
| $size="md" | ||
| $weight="bold" | ||
| $textAlign="center" | ||
| $margin="0" | ||
| $theme="neutral" | ||
| $variation="primary" | ||
| > | ||
| {t('Error 503')} | ||
| </Text> | ||
| <Text | ||
| as="p" | ||
| $textAlign="center" | ||
| $maxWidth="330px" | ||
| $theme="neutral" | ||
| $variation="secondary" | ||
| $margin="0" | ||
| $size="sm" | ||
| > | ||
| {t('The server is temporarily overloaded or unavailable')} | ||
| </Text> | ||
| </Box> | ||
| <BoxButton | ||
| $direction="row" | ||
| $align="center" | ||
| $gap="3xs" | ||
| $theme="neutral" | ||
| $variation="tertiary" | ||
| onClick={() => | ||
| safeTarget | ||
| ? window.location.assign(safeTarget) | ||
|
|
||
| : window.location.reload() | ||
| } | ||
| > | ||
| <Icon | ||
| iconName="refresh" | ||
| variant="symbols-outlined" | ||
| $size="sm" | ||
| $theme="neutral" | ||
| $variation="tertiary" | ||
| aria-hidden="true" | ||
| /> | ||
| <Text | ||
| $size="sm" | ||
| $theme="neutral" | ||
| $variation="tertiary" | ||
| $weight={500} | ||
| $margin="0" | ||
| > | ||
| {t('Refresh page')} | ||
| </Text> | ||
| </BoxButton> | ||
| </Box> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './Error503'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './components'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import Head from 'next/head'; | ||
| import { useRouter } from 'next/router'; | ||
| import { ReactElement } from 'react'; | ||
| import { useTranslation } from 'react-i18next'; | ||
|
|
||
| import { Error503 } from '@/features/errors'; | ||
| import { StandalonePageLayout } from '@/layouts'; | ||
| import { NextPageWithLayout } from '@/types/next'; | ||
|
|
||
| const Page: NextPageWithLayout = () => { | ||
| const { t } = useTranslation(); | ||
| const { query } = useRouter(); | ||
| const from = Array.isArray(query.from) ? query.from[0] : query.from; | ||
| const refreshTarget = | ||
| from?.startsWith('/') && !from.startsWith('//') ? from : undefined; | ||
|
|
||
| return ( | ||
| <> | ||
| <Head> | ||
| <meta name="robots" content="noindex" /> | ||
| <title>{`${t('Error 503')} - ${t('Docs')}`}</title> | ||
| <meta | ||
| property="og:title" | ||
| content={`${t('Error 503')} - ${t('Docs')}`} | ||
| key="title" | ||
| /> | ||
| </Head> | ||
| <Error503 refreshTarget={refreshTarget} /> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| Page.getLayout = function getLayout(page: ReactElement) { | ||
| return <StandalonePageLayout>{page}</StandalonePageLayout>; | ||
| }; | ||
|
|
||
| export default Page; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Exercise the refresh action in the E2E test.
This assertion checks only button visibility. It does not invoke the button, so a broken
window.location.assignorwindow.location.reloadpath can still pass. Click the button with a safefrompath and assert the resulting URL. Also cover the reload branch if it is part of the required behavior.🤖 Prompt for AI Agents