Commit 50ffed22 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #683 from blockscout/csp-enhancement

parents c02fe2cd 0d4652cc
......@@ -12,6 +12,22 @@ async function headers() {
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'X-DNS-Prefetch-Control',
value: 'on',
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin',
},
],
},
];
......
import * as descriptors from './policies';
import { makePolicyString, mergeDescriptors } from './utils';
function generateCspPolicy() {
const policyDescriptor = mergeDescriptors(
descriptors.app(),
descriptors.ad(),
descriptors.googleAnalytics(),
descriptors.googleFonts(),
descriptors.googleReCaptcha(),
descriptors.sentry(),
descriptors.walletConnect(),
);
return makePolicyString(policyDescriptor);
}
export default generateCspPolicy;
import type CspDev from 'csp-dev';
import isSelfHosted from 'lib/isSelfHosted';
export function ad(): CspDev.DirectiveDescriptor {
if (!isSelfHosted()) {
return {};
}
return {
'connect-src': [
'coinzilla.com',
'*.coinzilla.com',
'request-global.czilladx.com',
],
'frame-src': [
'request-global.czilladx.com',
],
'script-src': [
'coinzillatag.com',
'servedbyadbutler.com',
// what hash is this?
'\'sha256-wMOeDjJaOTjCfNjluteV+tSqHW547T89sgxd8W6tQJM=\'',
// what hash is this?
'\'sha256-FcyIn1h7zra8TVnnRhYrwrplxJW7dpD5TV7kP2AG/kI=\'',
],
'img-src': [
'servedbyadbutler.com',
'cdn.coinzilla.io',
],
'font-src': [
'request-global.czilladx.com',
],
};
}
import type CspDev from 'csp-dev';
import appConfig from 'configs/app/config';
import featuredNetworks from 'lib/networks/featuredNetworks';
const KEY_WORDS = {
BLOB: 'blob:',
DATA: 'data:',
NONE: '\'none\'',
REPORT_SAMPLE: `'report-sample'`,
SELF: '\'self\'',
STRICT_DYNAMIC: `'strict-dynamic'`,
UNSAFE_INLINE: '\'unsafe-inline\'',
UNSAFE_EVAL: '\'unsafe-eval\'',
};
import { KEY_WORDS } from '../utils';
const MAIN_DOMAINS = [
`*.${ appConfig.host }`,
......@@ -20,16 +12,6 @@ const MAIN_DOMAINS = [
// eslint-disable-next-line no-restricted-properties
const REPORT_URI = process.env.SENTRY_CSP_REPORT_URI;
function getNetworksExternalAssetsHosts() {
const icons = featuredNetworks
.filter(({ icon }) => typeof icon === 'string')
.map(({ icon }) => new URL(icon as string).host);
const logo = appConfig.network.logo ? new URL(appConfig.network.logo).host : undefined;
return logo ? icons.concat(logo) : icons;
}
function getMarketplaceAppsHosts() {
return {
frames: appConfig.marketplaceAppList.map(({ url }) => new URL(url).host),
......@@ -37,25 +19,12 @@ function getMarketplaceAppsHosts() {
};
}
// we cannot use lodash/uniq in middleware code since it calls new Set() and it'is causing an error in Nextjs
// "Dynamic Code Evaluation (e. g. 'eval', 'new Function', 'WebAssembly.compile') not allowed in Edge Runtime"
function unique(array: Array<string | undefined>) {
const set: Record<string, boolean> = {};
for (const item of array) {
item && (set[item] = true);
}
return Object.keys(set);
}
function makePolicyMap() {
export function app(): CspDev.DirectiveDescriptor {
const marketplaceAppsHosts = getMarketplaceAppsHosts();
return {
'default-src': [
// KEY_WORDS.NONE,
// temporarily, see if warnings for "/_next/static/chunks/8861-ad3efb7f624b7bc1.js" go away
...MAIN_DOMAINS,
KEY_WORDS.NONE,
],
'connect-src': [
......@@ -65,9 +34,6 @@ function makePolicyMap() {
// webpack hmr in safari doesn't recognize localhost as 'self' for some reason
appConfig.isDev ? 'ws://localhost:3000/_next/webpack-hmr' : '',
// client error monitoring
'sentry.io', '*.sentry.io',
// API
appConfig.api.endpoint,
appConfig.api.socket,
......@@ -75,67 +41,28 @@ function makePolicyMap() {
// chain RPC server
appConfig.network.rpcUrl,
// ad
'request-global.czilladx.com',
// walletconnect
'*.walletconnect.com',
'wss://*.bridge.walletconnect.org',
'wss://www.walletlink.org',
// RPC providers
'https://infragrid.v.network',
'https://infragrid.v.network', // RPC providers
// github (spec for api-docs page)
'raw.githubusercontent.com',
// google analytics
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
'https://stats.g.doubleclick.net',
],
].filter(Boolean),
'script-src': [
KEY_WORDS.SELF,
...MAIN_DOMAINS,
// next.js generates and rebuilds source maps in dev using eval()
// https://github.com/vercel/next.js/issues/14221#issuecomment-657258278
appConfig.isDev ? KEY_WORDS.UNSAFE_EVAL : '',
...MAIN_DOMAINS,
// hash of ColorModeScript
'\'sha256-e7MRMmTzLsLQvIy1iizO1lXf7VWYoQ6ysj5fuUzvRwE=\'',
// ad
'coinzillatag.com',
'servedbyadbutler.com',
'\'sha256-wMOeDjJaOTjCfNjluteV+tSqHW547T89sgxd8W6tQJM=\'',
'\'sha256-FcyIn1h7zra8TVnnRhYrwrplxJW7dpD5TV7kP2AG/kI=\'',
// reCAPTCHA from google
'https://www.google.com/recaptcha/api.js',
'https://www.gstatic.com',
'https://translate.google.com',
'\'sha256-FDyPg8CqqIpPAfGVKx1YeKduyLs0ghNYWII21wL+7HM=\'',
// google analytics
'\'sha256-NTmEg2dBnojQfTYrYJEmp3nG7V66756qPbQMCIBrctk=\'',
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
],
'style-src': [
KEY_WORDS.SELF,
...MAIN_DOMAINS,
// google fonts
'fonts.googleapis.com',
// reCAPTCHA from google
'https://www.gstatic.com',
// yes, it is unsafe as it stands, but
// - we cannot use hashes because all styles are generated dynamically
// - we cannot use nonces since we are not following along SSR path
......@@ -147,52 +74,29 @@ function makePolicyMap() {
'img-src': [
KEY_WORDS.SELF,
KEY_WORDS.DATA,
...MAIN_DOMAINS,
// github assets (e.g trustwallet token icons)
'raw.githubusercontent.com',
// auth0 assets and avatars
's.gravatar.com',
'i0.wp.com', 'i1.wp.com', 'i2.wp.com', 'i3.wp.com',
'lh3.googleusercontent.com', // google avatars
'avatars.githubusercontent.com', // github avatars
// network assets
...getNetworksExternalAssetsHosts(),
// marketplace apps logos
...marketplaceAppsHosts.logos,
// ad
'servedbyadbutler.com',
'cdn.coinzilla.io',
// walletconnect
'*.walletconnect.com',
// token's media
'ipfs.io',
// reCAPTCHA from google
'https://translate.google.com',
'https://www.gstatic.com',
// we agreed that using wildcard for images is mostly safe
// why do we have to use it? the main reason is that for NFT and inventory pages we get resources urls from API only on the client
// so they cannot be added to the policy on the server
// there could be 3 possible workarounds
// a/ use server side rendering approach, that we don't want to do
// b/ wrap every image/video in iframe with a source to static page for which we enforce certain img-src rule;
// the downsides is page performance slowdown and code complexity (have to manage click on elements, color mode for
// embedded page, etc)
// c/ use wildcard for img-src directive; this can lead to some security vulnerabilities but we were unable to find evidence
// that loose img-src directive alone could cause serious flaws on the site as long as we keep script-src and connect-src strict
//
// feel free to propose alternative solution and fix this
'*',
],
// google analytics
'https://www.google-analytics.com',
'media-src': [
'*', // see comment for img-src directive
],
'font-src': [
KEY_WORDS.DATA,
// google fonts
'fonts.gstatic.com',
'fonts.googleapis.com',
],
'prefetch-src': [
...MAIN_DOMAINS,
],
'object-src': [
......@@ -205,40 +109,12 @@ function makePolicyMap() {
'frame-src': [
...marketplaceAppsHosts.frames,
// ad
'request-global.czilladx.com',
// reCAPTCHA from google
// 'https://www.google.com/',
'https://www.google.com/recaptcha/api2/anchor',
'https://www.google.com/recaptcha/api2/bframe',
],
...(REPORT_URI ? {
...(REPORT_URI && !appConfig.isDev ? {
'report-uri': [
REPORT_URI,
],
} : {}),
};
}
function getCspPolicy() {
const policyMap = makePolicyMap();
const policyString = Object.entries(policyMap)
.map(([ key, value ]) => {
if (!value || value.length === 0) {
return;
}
const uniqueValues = unique(value);
return [ key, uniqueValues.join(' ') ].join(' ');
})
.filter(Boolean)
.join(';');
return policyString;
}
export default getCspPolicy;
import type CspDev from 'csp-dev';
import appConfig from 'configs/app/config';
export function googleAnalytics(): CspDev.DirectiveDescriptor {
if (!appConfig.googleAnalytics.propertyId) {
return {};
}
return {
'connect-src': [
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
'https://stats.g.doubleclick.net',
],
'script-src': [
// inline script hash, see ui/shared/GoogleAnalytics.tsx
'\'sha256-NTmEg2dBnojQfTYrYJEmp3nG7V66756qPbQMCIBrctk=\'',
'https://www.googletagmanager.com',
'https://www.google-analytics.com',
],
'img-src': [
'https://www.google-analytics.com',
],
};
}
import type CspDev from 'csp-dev';
export function googleFonts(): CspDev.DirectiveDescriptor {
// we use Inter and Poppins in the app
return {
'style-src': [
'fonts.googleapis.com',
],
'font-src': [
'fonts.gstatic.com',
'fonts.googleapis.com',
],
};
}
import type CspDev from 'csp-dev';
import appConfig from 'configs/app/config';
export function googleReCaptcha(): CspDev.DirectiveDescriptor {
if (!appConfig.reCaptcha.siteKey) {
return {};
}
return {
'script-src': [
'https://www.google.com/recaptcha/api.js',
'https://www.gstatic.com',
'https://translate.google.com',
'\'sha256-FDyPg8CqqIpPAfGVKx1YeKduyLs0ghNYWII21wL+7HM=\'',
],
'style-src': [
'https://www.gstatic.com',
],
'img-src': [
'https://translate.google.com',
'https://www.gstatic.com',
],
'frame-src': [
'https://www.google.com/recaptcha/api2/anchor',
'https://www.google.com/recaptcha/api2/bframe',
],
};
}
export { ad } from './ad';
export { app } from './app';
export { googleAnalytics } from './googleAnalytics';
export { googleFonts } from './googleFonts';
export { googleReCaptcha } from './googleReCaptcha';
export { sentry } from './sentry';
export { walletConnect } from './walletConnect';
import type CspDev from 'csp-dev';
export function sentry(): CspDev.DirectiveDescriptor {
return {
'connect-src': [
'sentry.io',
'*.sentry.io',
],
};
}
import type CspDev from 'csp-dev';
import appConfig from 'configs/app/config';
export function walletConnect(): CspDev.DirectiveDescriptor {
if (!appConfig.walletConnect.projectId || !appConfig.network.rpcUrl) {
return {};
}
return {
'connect-src': [
'*.walletconnect.com',
'wss://*.bridge.walletconnect.org',
'wss://www.walletlink.org',
],
'img-src': [
'*.walletconnect.com',
],
};
}
import type CspDev from 'csp-dev';
export const KEY_WORDS = {
BLOB: 'blob:',
DATA: 'data:',
NONE: '\'none\'',
REPORT_SAMPLE: `'report-sample'`,
SELF: '\'self\'',
STRICT_DYNAMIC: `'strict-dynamic'`,
UNSAFE_INLINE: '\'unsafe-inline\'',
UNSAFE_EVAL: '\'unsafe-eval\'',
};
// we cannot use lodash/uniq and lodash/mergeWith in middleware code since it calls new Set() and it'is causing an error in Next.js
// "Dynamic Code Evaluation (e. g. 'eval', 'new Function', 'WebAssembly.compile') not allowed in Edge Runtime"
export function unique(array: Array<string | undefined>) {
const set: Record<string, boolean> = {};
for (const item of array) {
item && (set[item] = true);
}
return Object.keys(set);
}
export function mergeDescriptors(...descriptors: Array<CspDev.DirectiveDescriptor>) {
return descriptors.reduce((result, item) => {
for (const _key in item) {
const key = _key as CspDev.Directive;
const value = item[key];
if (!value) {
continue;
}
if (result[key]) {
result[key]?.push(...value);
} else {
result[key] = [ ...value ];
}
}
return result;
}, {} as CspDev.DirectiveDescriptor);
}
export function makePolicyString(policyDescriptor: CspDev.DirectiveDescriptor) {
return Object.entries(policyDescriptor)
.map(([ key, value ]) => {
if (!value || value.length === 0) {
return;
}
const uniqueValues = unique(value);
return [ key, uniqueValues.join(' ') ].join(' ');
})
.filter(Boolean)
.join(';');
}
......@@ -4,9 +4,9 @@ import { route } from 'nextjs-routes';
import appConfig from 'configs/app/config';
import { NAMES } from 'lib/cookies';
import getCspPolicy from 'lib/csp/getCspPolicy';
import generateCspPolicy from 'lib/csp/generateCspPolicy';
const cspPolicy = getCspPolicy();
const cspPolicy = generateCspPolicy();
export function middleware(req: NextRequest) {
const isPageRequest = req.headers.get('accept')?.includes('text/html');
......@@ -28,7 +28,7 @@ export function middleware(req: NextRequest) {
const end = Date.now();
const res = NextResponse.next();
res.headers.append('Content-Security-Policy-Report-Only', cspPolicy);
res.headers.append(appConfig.isDev ? 'Content-Security-Policy' : 'Content-Security-Policy-Report-Only', cspPolicy);
res.headers.append('Server-Timing', `middleware;dur=${ end - start }`);
return res;
......
import type { NextApiRequest, NextApiResponse } from 'next';
import nodeFetch from 'node-fetch';
import { httpLogger } from 'lib/api/logger';
import getQueryParamString from 'lib/router/getQueryParamString';
export default async function mediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
httpLogger(req, res);
try {
const url = getQueryParamString(req.query.url);
const response = await nodeFetch(url, { method: 'HEAD' });
if (response.status !== 200) {
throw new Error();
}
const contentType = response.headers.get('content-type');
const mediaType = contentType?.startsWith('video') ? 'video' : 'image';
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
}
}
......@@ -15,6 +15,7 @@ declare module "nextjs-routes" {
| DynamicRoute<"/address/[hash]/contract_verification", { "hash": string }>
| DynamicRoute<"/address/[hash]", { "hash": string }>
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/media-type">
| StaticRoute<"/api/proxy">
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
......
......@@ -63,6 +63,7 @@ const NftImage = ({ url, className, fallbackPadding, objectFit }: Props) => {
fallback={ url && isLoading ? <Skeleton/> : <Fallback className={ className } padding={ fallbackPadding }/> }
onError={ handleLoadError }
onLoad={ handleLoad }
loading={ url ? 'lazy' : undefined }
/>
</AspectRatio>
);
......
import { AspectRatio, chakra, Skeleton } from '@chakra-ui/react';
import { route } from 'nextjs-routes';
import React from 'react';
import NftImage from './NftImage';
import NftVideo from './NftVideo';
import type { MediaType } from './utils';
import { getPreliminaryMediaType } from './utils';
interface Props {
imageUrl: string | null;
......@@ -11,7 +14,7 @@ interface Props {
}
const NftMedia = ({ imageUrl, animationUrl, className }: Props) => {
const [ type, setType ] = React.useState<'image' | 'video' | undefined>(!animationUrl ? 'image' : undefined);
const [ type, setType ] = React.useState<MediaType | undefined>(!animationUrl ? 'image' : undefined);
React.useEffect(() => {
if (!animationUrl) {
......@@ -20,10 +23,26 @@ const NftMedia = ({ imageUrl, animationUrl, className }: Props) => {
// media could be either gif or video
// so we pre-fetch the resources in order to get its content type
fetch(animationUrl, { method: 'HEAD' })
.then((response) => {
const contentType = response.headers.get('content-type');
setType(contentType?.startsWith('video') ? 'video' : 'image');
// have to do it via Node.js due to strict CSP for connect-src
// but in order not to abuse our server firstly we check file url extension
// and if it is valid we will trust it and display corresponding media component
const preliminaryType = getPreliminaryMediaType(animationUrl);
if (preliminaryType) {
setType(preliminaryType);
return;
}
const url = route({ pathname: '/api/media-type', query: { url: animationUrl } });
fetch(url)
.then((response) => response.json())
.then((_data) => {
const data = _data as { type: MediaType | undefined };
setType(data.type || 'image');
})
.catch(() => {
setType('image');
});
}, [ animationUrl ]);
......
export type MediaType = 'image' | 'video';
const IMAGE_EXTENSIONS = [
'.jpg', 'jpeg',
'.png',
'.gif',
'.svg',
];
const VIDEO_EXTENSIONS = [
'.mp4',
'.webm',
'.ogg',
];
export function getPreliminaryMediaType(url: string): MediaType | undefined {
if (IMAGE_EXTENSIONS.some((ext) => url.endsWith(ext))) {
return 'image';
}
if (url.startsWith('data:image')) {
return 'image';
}
if (VIDEO_EXTENSIONS.some((ext) => url.endsWith(ext))) {
return 'video';
}
}
......@@ -4081,6 +4081,11 @@
dependencies:
"@types/node" "*"
"@types/csp-dev@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/csp-dev/-/csp-dev-1.0.0.tgz#59e2fd69f276988b349765c2f6a39ea0a4a1a161"
integrity sha512-OTHJTGqXvgFu1AE4Eo8cu+jgkQChpzTOHpMDrVkxAmNORVz7/11Zjj2NXZyWQcxibf18C966bU1JWtANrR8uJQ==
"@types/d3-array@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.3.tgz#87d990bf504d14ad6b16766979d04e943c046dac"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment