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
8 changes: 7 additions & 1 deletion src/app/runtime-config-script.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { headers } from 'next/headers';
import { getAnglesApiUrl, RUNTIME_CONFIG_GLOBAL } from '../utils/runtime-config';

// Server component. Rendered before any client code runs so that
// `window.__ANGLES_CONFIG__` is populated by the time modules that read it
// (e.g. the axios base URL in Shell.js) execute.
export default function RuntimeConfigScript() {
//
// Carries the request's CSP nonce (set by src/proxy.js); without it the policy blocks
// this inline script like any other.
export default async function RuntimeConfigScript() {
const config = { anglesApiUrl: getAnglesApiUrl() };
const nonce = (await headers()).get('x-nonce') || undefined;
return (
<script
nonce={nonce}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: `window.${RUNTIME_CONFIG_GLOBAL}=${JSON.stringify(config).replace(/</g, '\\u003c')};`,
Expand Down
23 changes: 14 additions & 9 deletions src/components/features/execution-timeline/StepsTimeline.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
/* eslint react/no-array-index-key: [0] */
import React from 'react';
import DomPurify from 'dompurify';
import parse from 'html-react-parser';
import Moment from 'react-moment';

import {
Expand Down Expand Up @@ -50,12 +48,19 @@ const StepsTimeline = function (props) {
}
};

const convertTextToLinks = (content) => {
const reg = /(http:\/\/|https:\/\/)((\w|=|\?|\.|\/|#|&|%|\+|-)+)/g;
if (content) {
return content.replace(reg, "<a href='$1$2' target='_blank'>$1$2</a>");
}
return '';
// Step info comes from test results, i.e. from anyone with an API token for the team, so
// it is rendered as text: HTML in it is shown, never interpreted. (It used to be parsed
// as HTML after DOMPurify, which still let through forms, inline styles and off-site
// links - enough for a convincing fake "session expired" form.) Only http(s) URLs are
// turned into links, built as React elements rather than markup.
const URL_PATTERN = /(https?:\/\/[\w=?./#&%+:~@,;!*'()$-]+)/g;
const renderInfo = (content) => {
if (!content) return '';
return String(content).split(URL_PATTERN).map((part, index) => (
index % 2 === 1 ? (
<a key={`${index}-${part}`} href={part} target="_blank" rel="noopener noreferrer">{part}</a>
) : part
));
};

const renderScreenshot = (step) => {
Expand Down Expand Up @@ -111,7 +116,7 @@ const StepsTimeline = function (props) {
<Moment utc format="HH:mm:ss">{step.timestamp}</Moment>
</span>
<span className="step-name step-name-info">
{parse(DomPurify.sanitize(convertTextToLinks(step.info)))}
{renderInfo(step.info)}
</span>
</div>
);
Expand Down
80 changes: 80 additions & 0 deletions src/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NextResponse } from 'next/server';
import { getAnglesApiBaseUrl } from './utils/runtime-config';

/*
* Security headers for every page.
*
* - Content-Security-Policy with a per-request nonce. Next.js reads the nonce from the
* request's CSP header and puts it on its own scripts; RuntimeConfigScript reads it from
* `x-nonce`. Scripts without it - anything injected into a page - do not run.
* - `frame-ancestors 'none'` and X-Frame-Options: the UI cannot be framed by another site,
* so it cannot be used for clickjacking.
* - `form-action 'self'`: a form can only submit to the UI itself, never off-site.
*
* The API is on another origin in most deployments and its URL is only known at runtime
* (ANGLES_API_BASE_URL), which is why this is built per request here rather than as a
* static header in next.config.mjs.
*/

// The origin (scheme, host and port) of a configured URL, or undefined if it is not one.
const originOf = (url) => {
try {
return new URL(url).origin;
} catch (error) {
return undefined;
}
};

export const buildContentSecurityPolicy = (nonce, apiOrigin, isDev) => {
const connect = ["'self'", apiOrigin].filter(Boolean).join(' ');
return [
"default-src 'self'",
// 'strict-dynamic' lets the nonced Next.js runtime load the page's other chunks. In
// development React needs eval for its error overlays; production does not.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''}`,
// rsuite and ApexCharts set inline styles at runtime, which a nonce cannot cover.
"style-src 'self' 'unsafe-inline'",
// Screenshots arrive as data: URIs; attachments are shown from blob: URLs.
"img-src 'self' data: blob:",
"media-src 'self' blob:",
"font-src 'self' data:",
`connect-src ${connect}`,
// HTML snapshots are shown in a srcdoc iframe, which inherits this policy.
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ');
};

export function proxy(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const isDev = process.env.NODE_ENV === 'development';
const csp = buildContentSecurityPolicy(nonce, originOf(getAnglesApiBaseUrl()), isDev);

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', csp);

const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
// For browsers that predate frame-ancestors.
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}

export const config = {
matcher: [
{
// Pages only: static chunks, images and public assets need no policy.
source: '/((?!_next/static|_next/image|favicon.png|assets/|manifest.json|robots.txt).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};