diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e90b89df..2089b2802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ You can also check the # Unreleased - Features + - Implemented Content Security Policy (CSP) - It's now possible to export charts as images - Added Footer to the Profile Page - Fixes + - Addressed security flaw allowing the injection of arbitrary URLs + in the `sourceUrl` parameter in the GraphQL API - Color mapping is now correctly kept up to date in case of editing an old chart and the cube has been updated in the meantime and contains new values in the color dimension @@ -51,6 +54,7 @@ You can also check the - Added auto-generated JSON Schema files for configurator state and chart config and improved preview charts via API documentation + # [5.0.2] - 2024-11-28 - Features diff --git a/app/domain/datasource/index.ts b/app/domain/datasource/index.ts index 24c1ea17d..6924002fe 100644 --- a/app/domain/datasource/index.ts +++ b/app/domain/datasource/index.ts @@ -7,6 +7,8 @@ import { } from "@/domain/datasource/constants"; import { ENDPOINT } from "@/domain/env"; +export { isDataSourceUrlAllowed, type DataSourceUrl } from "./urls"; + export const parseDataSource = (stringifiedSource: string): DataSource => { const [type, url] = stringifiedSource.split("+") as [ DataSource["type"], diff --git a/app/domain/datasource/urls.ts b/app/domain/datasource/urls.ts new file mode 100644 index 000000000..f678a9312 --- /dev/null +++ b/app/domain/datasource/urls.ts @@ -0,0 +1,17 @@ +import { SOURCE_OPTIONS } from "@/domain/datasource/constants"; + +const allowedSourceLabels = JSON.parse( + process.env.WHITELISTED_DATA_SOURCES ?? "[]" +); + +const allowedSources = SOURCE_OPTIONS.filter((o) => + allowedSourceLabels.includes(o.label) +); + +const allowedDataSourceUrls = allowedSources.map((o) => o.value.split("+")[1]); + +export type DataSourceUrl = string & {}; + +export const isDataSourceUrlAllowed = (url: string): url is DataSourceUrl => { + return typeof url === "string" && allowedDataSourceUrls.includes(url); +}; diff --git a/app/graphql/queries/data-cubes.graphql b/app/graphql/queries/data-cubes.graphql index ddcea4c70..d7aa7f657 100644 --- a/app/graphql/queries/data-cubes.graphql +++ b/app/graphql/queries/data-cubes.graphql @@ -1,6 +1,6 @@ query SearchCubes( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $query: String $order: SearchCubeResultOrder @@ -26,7 +26,7 @@ query SearchCubes( query DataCubeLatestIri( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $cubeFilter: DataCubeLatestIriFilter! ) { dataCubeLatestIri( @@ -50,7 +50,7 @@ query DataCubeUnversionedIri( query DataCubeComponents( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubeComponentFilter! ) { @@ -64,7 +64,7 @@ query DataCubeComponents( query DataCubeDimensionGeoShapes( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubeDimensionGeoShapesCubeFilter! ) { @@ -78,7 +78,7 @@ query DataCubeDimensionGeoShapes( query DataCubeMetadata( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubeMetadataFilter! ) { @@ -92,7 +92,7 @@ query DataCubeMetadata( query DataCubeComponentTermsets( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubeTermsetFilter! ) { @@ -106,7 +106,7 @@ query DataCubeComponentTermsets( query DataCubeObservations( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubeObservationFilter! ) { @@ -120,7 +120,7 @@ query DataCubeObservations( query DataCubePreview( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $locale: String! $cubeFilter: DataCubePreviewFilter! ) { @@ -134,7 +134,7 @@ query DataCubePreview( query PossibleFilters( $sourceType: String! - $sourceUrl: String! + $sourceUrl: DataSourceUrl! $cubeFilter: DataCubePossibleFiltersCubeFilter! ) { possibleFilters( diff --git a/app/graphql/query-hooks.ts b/app/graphql/query-hooks.ts index 4c252be98..c4a64583d 100644 --- a/app/graphql/query-hooks.ts +++ b/app/graphql/query-hooks.ts @@ -3,6 +3,7 @@ import { DataCubeComponents } from '../domain/data'; import { DataCubeMetadata } from '../domain/data'; import { DataCubeObservations } from '../domain/data'; import { DataCubePreview } from '../domain/data'; +import { DataSourceUrl } from '../domain/datasource'; import { DimensionValue } from '../domain/data'; import { Filters } from '../configurator'; import { GeoShapes } from '../domain/data'; @@ -31,6 +32,7 @@ export type Scalars = { DataCubeMetadata: DataCubeMetadata; DataCubeObservations: DataCubeObservations; DataCubePreview: DataCubePreview; + DataSourceUrl: DataSourceUrl; DimensionValue: DimensionValue; FilterValue: any; Filters: Filters; @@ -46,6 +48,7 @@ export type Scalars = { }; + export type DataCubeComponentFilter = { iri: Scalars['String']; filters?: Maybe; @@ -124,6 +127,7 @@ export type DataCubeUnversionedIriFilter = { + export type PossibleFilterValue = { __typename: 'PossibleFilterValue'; type: Scalars['String']; @@ -148,7 +152,7 @@ export type Query = { export type QueryDataCubeLatestIriArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubeLatestIriFilter; }; @@ -162,7 +166,7 @@ export type QueryDataCubeUnversionedIriArgs = { export type QueryDataCubeComponentsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeComponentFilter; }; @@ -170,7 +174,7 @@ export type QueryDataCubeComponentsArgs = { export type QueryDataCubeComponentTermsetsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeTermsetFilter; }; @@ -178,7 +182,7 @@ export type QueryDataCubeComponentTermsetsArgs = { export type QueryDataCubeMetadataArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeMetadataFilter; }; @@ -186,7 +190,7 @@ export type QueryDataCubeMetadataArgs = { export type QueryDataCubeObservationsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeObservationFilter; }; @@ -194,7 +198,7 @@ export type QueryDataCubeObservationsArgs = { export type QueryDataCubePreviewArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubePreviewFilter; }; @@ -202,14 +206,14 @@ export type QueryDataCubePreviewArgs = { export type QueryPossibleFiltersArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubePossibleFiltersCubeFilter; }; export type QuerySearchCubesArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale?: Maybe; query?: Maybe; order?: Maybe; @@ -221,7 +225,7 @@ export type QuerySearchCubesArgs = { export type QueryDataCubeDimensionGeoShapesArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeDimensionGeoShapesCubeFilter; }; @@ -291,7 +295,7 @@ export enum TimeUnit { export type SearchCubesQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; query?: Maybe; order?: Maybe; @@ -305,7 +309,7 @@ export type SearchCubesQuery = { __typename: 'Query', searchCubes: Array<{ __typ export type DataCubeLatestIriQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubeLatestIriFilter; }>; @@ -323,7 +327,7 @@ export type DataCubeUnversionedIriQuery = { __typename: 'Query', dataCubeUnversi export type DataCubeComponentsQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeComponentFilter; }>; @@ -333,7 +337,7 @@ export type DataCubeComponentsQuery = { __typename: 'Query', dataCubeComponents: export type DataCubeDimensionGeoShapesQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeDimensionGeoShapesCubeFilter; }>; @@ -343,7 +347,7 @@ export type DataCubeDimensionGeoShapesQuery = { __typename: 'Query', dataCubeDim export type DataCubeMetadataQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeMetadataFilter; }>; @@ -353,7 +357,7 @@ export type DataCubeMetadataQuery = { __typename: 'Query', dataCubeMetadata: Dat export type DataCubeComponentTermsetsQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeTermsetFilter; }>; @@ -363,7 +367,7 @@ export type DataCubeComponentTermsetsQuery = { __typename: 'Query', dataCubeComp export type DataCubeObservationsQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeObservationFilter; }>; @@ -373,7 +377,7 @@ export type DataCubeObservationsQuery = { __typename: 'Query', dataCubeObservati export type DataCubePreviewQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubePreviewFilter; }>; @@ -383,7 +387,7 @@ export type DataCubePreviewQuery = { __typename: 'Query', dataCubePreview: DataC export type PossibleFiltersQueryVariables = Exact<{ sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubePossibleFiltersCubeFilter; }>; @@ -392,7 +396,7 @@ export type PossibleFiltersQuery = { __typename: 'Query', possibleFilters: Array export const SearchCubesDocument = gql` - query SearchCubes($sourceType: String!, $sourceUrl: String!, $locale: String!, $query: String, $order: SearchCubeResultOrder, $includeDrafts: Boolean, $fetchDimensionTermsets: Boolean, $filters: [SearchCubeFilter!]) { + query SearchCubes($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $query: String, $order: SearchCubeResultOrder, $includeDrafts: Boolean, $fetchDimensionTermsets: Boolean, $filters: [SearchCubeFilter!]) { searchCubes( sourceType: $sourceType sourceUrl: $sourceUrl @@ -414,7 +418,7 @@ export function useSearchCubesQuery(options: Omit({ query: SearchCubesDocument, ...options }); }; export const DataCubeLatestIriDocument = gql` - query DataCubeLatestIri($sourceType: String!, $sourceUrl: String!, $cubeFilter: DataCubeLatestIriFilter!) { + query DataCubeLatestIri($sourceType: String!, $sourceUrl: DataSourceUrl!, $cubeFilter: DataCubeLatestIriFilter!) { dataCubeLatestIri( sourceType: $sourceType sourceUrl: $sourceUrl @@ -440,7 +444,7 @@ export function useDataCubeUnversionedIriQuery(options: Omit({ query: DataCubeUnversionedIriDocument, ...options }); }; export const DataCubeComponentsDocument = gql` - query DataCubeComponents($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubeComponentFilter!) { + query DataCubeComponents($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubeComponentFilter!) { dataCubeComponents( sourceType: $sourceType sourceUrl: $sourceUrl @@ -454,7 +458,7 @@ export function useDataCubeComponentsQuery(options: Omit({ query: DataCubeComponentsDocument, ...options }); }; export const DataCubeDimensionGeoShapesDocument = gql` - query DataCubeDimensionGeoShapes($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubeDimensionGeoShapesCubeFilter!) { + query DataCubeDimensionGeoShapes($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubeDimensionGeoShapesCubeFilter!) { dataCubeDimensionGeoShapes( sourceType: $sourceType sourceUrl: $sourceUrl @@ -468,7 +472,7 @@ export function useDataCubeDimensionGeoShapesQuery(options: Omit({ query: DataCubeDimensionGeoShapesDocument, ...options }); }; export const DataCubeMetadataDocument = gql` - query DataCubeMetadata($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubeMetadataFilter!) { + query DataCubeMetadata($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubeMetadataFilter!) { dataCubeMetadata( sourceType: $sourceType sourceUrl: $sourceUrl @@ -482,7 +486,7 @@ export function useDataCubeMetadataQuery(options: Omit({ query: DataCubeMetadataDocument, ...options }); }; export const DataCubeComponentTermsetsDocument = gql` - query DataCubeComponentTermsets($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubeTermsetFilter!) { + query DataCubeComponentTermsets($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubeTermsetFilter!) { dataCubeComponentTermsets( sourceType: $sourceType sourceUrl: $sourceUrl @@ -496,7 +500,7 @@ export function useDataCubeComponentTermsetsQuery(options: Omit({ query: DataCubeComponentTermsetsDocument, ...options }); }; export const DataCubeObservationsDocument = gql` - query DataCubeObservations($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubeObservationFilter!) { + query DataCubeObservations($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubeObservationFilter!) { dataCubeObservations( sourceType: $sourceType sourceUrl: $sourceUrl @@ -510,7 +514,7 @@ export function useDataCubeObservationsQuery(options: Omit({ query: DataCubeObservationsDocument, ...options }); }; export const DataCubePreviewDocument = gql` - query DataCubePreview($sourceType: String!, $sourceUrl: String!, $locale: String!, $cubeFilter: DataCubePreviewFilter!) { + query DataCubePreview($sourceType: String!, $sourceUrl: DataSourceUrl!, $locale: String!, $cubeFilter: DataCubePreviewFilter!) { dataCubePreview( sourceType: $sourceType sourceUrl: $sourceUrl @@ -524,7 +528,7 @@ export function useDataCubePreviewQuery(options: Omit({ query: DataCubePreviewDocument, ...options }); }; export const PossibleFiltersDocument = gql` - query PossibleFilters($sourceType: String!, $sourceUrl: String!, $cubeFilter: DataCubePossibleFiltersCubeFilter!) { + query PossibleFilters($sourceType: String!, $sourceUrl: DataSourceUrl!, $cubeFilter: DataCubePossibleFiltersCubeFilter!) { possibleFilters( sourceType: $sourceType sourceUrl: $sourceUrl diff --git a/app/graphql/resolver-types.ts b/app/graphql/resolver-types.ts index 44fa910f8..68824a8b6 100644 --- a/app/graphql/resolver-types.ts +++ b/app/graphql/resolver-types.ts @@ -3,6 +3,7 @@ import { DataCubeComponents } from '../domain/data'; import { DataCubeMetadata } from '../domain/data'; import { DataCubeObservations } from '../domain/data'; import { DataCubePreview } from '../domain/data'; +import { DataSourceUrl } from '../domain/datasource'; import { DimensionValue } from '../domain/data'; import { Filters } from '../configurator'; import { GeoShapes } from '../domain/data'; @@ -31,6 +32,7 @@ export type Scalars = { DataCubeMetadata: DataCubeMetadata; DataCubeObservations: DataCubeObservations; DataCubePreview: DataCubePreview; + DataSourceUrl: DataSourceUrl; DimensionValue: DimensionValue; FilterValue: any; Filters: Filters; @@ -46,6 +48,7 @@ export type Scalars = { }; + export type DataCubeComponentFilter = { iri: Scalars['String']; filters?: Maybe; @@ -124,6 +127,7 @@ export type DataCubeUnversionedIriFilter = { + export type PossibleFilterValue = { __typename?: 'PossibleFilterValue'; type: Scalars['String']; @@ -148,7 +152,7 @@ export type Query = { export type QueryDataCubeLatestIriArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubeLatestIriFilter; }; @@ -162,7 +166,7 @@ export type QueryDataCubeUnversionedIriArgs = { export type QueryDataCubeComponentsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeComponentFilter; }; @@ -170,7 +174,7 @@ export type QueryDataCubeComponentsArgs = { export type QueryDataCubeComponentTermsetsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeTermsetFilter; }; @@ -178,7 +182,7 @@ export type QueryDataCubeComponentTermsetsArgs = { export type QueryDataCubeMetadataArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeMetadataFilter; }; @@ -186,7 +190,7 @@ export type QueryDataCubeMetadataArgs = { export type QueryDataCubeObservationsArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeObservationFilter; }; @@ -194,7 +198,7 @@ export type QueryDataCubeObservationsArgs = { export type QueryDataCubePreviewArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubePreviewFilter; }; @@ -202,14 +206,14 @@ export type QueryDataCubePreviewArgs = { export type QueryPossibleFiltersArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; cubeFilter: DataCubePossibleFiltersCubeFilter; }; export type QuerySearchCubesArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale?: Maybe; query?: Maybe; order?: Maybe; @@ -221,7 +225,7 @@ export type QuerySearchCubesArgs = { export type QueryDataCubeDimensionGeoShapesArgs = { sourceType: Scalars['String']; - sourceUrl: Scalars['String']; + sourceUrl: Scalars['DataSourceUrl']; locale: Scalars['String']; cubeFilter: DataCubeDimensionGeoShapesCubeFilter; }; @@ -375,6 +379,7 @@ export type ResolversTypes = ResolversObject<{ DataCubeTermsetFilter: DataCubeTermsetFilter; DataCubeTheme: ResolverTypeWrapper; DataCubeUnversionedIriFilter: DataCubeUnversionedIriFilter; + DataSourceUrl: ResolverTypeWrapper; DimensionValue: ResolverTypeWrapper; FilterValue: ResolverTypeWrapper; Filters: ResolverTypeWrapper; @@ -421,6 +426,7 @@ export type ResolversParentTypes = ResolversObject<{ DataCubeTermsetFilter: DataCubeTermsetFilter; DataCubeTheme: DataCubeTheme; DataCubeUnversionedIriFilter: DataCubeUnversionedIriFilter; + DataSourceUrl: Scalars['DataSourceUrl']; DimensionValue: Scalars['DimensionValue']; FilterValue: Scalars['FilterValue']; Filters: Scalars['Filters']; @@ -441,6 +447,10 @@ export type ResolversParentTypes = ResolversObject<{ ValuePosition: Scalars['ValuePosition']; }>; +export type SafeUrlDirectiveArgs = { pattern?: Maybe; }; + +export type SafeUrlDirectiveResolver = DirectiveResolverFn; + export interface ComponentTermsetsScalarConfig extends GraphQLScalarTypeConfig { name: 'ComponentTermsets'; } @@ -479,6 +489,10 @@ export type DataCubeThemeResolvers; }>; +export interface DataSourceUrlScalarConfig extends GraphQLScalarTypeConfig { + name: 'DataSourceUrl'; +} + export interface DimensionValueScalarConfig extends GraphQLScalarTypeConfig { name: 'DimensionValue'; } @@ -570,6 +584,7 @@ export type Resolvers = ResolversObject<{ DataCubePreview?: GraphQLScalarType; DataCubeTermset?: DataCubeTermsetResolvers; DataCubeTheme?: DataCubeThemeResolvers; + DataSourceUrl?: GraphQLScalarType; DimensionValue?: GraphQLScalarType; FilterValue?: GraphQLScalarType; Filters?: GraphQLScalarType; @@ -594,3 +609,13 @@ export type Resolvers = ResolversObject<{ * Use "Resolvers" root object instead. If you wish to get "IResolvers", add "typesPrefix: I" to your config. */ export type IResolvers = Resolvers; +export type DirectiveResolvers = ResolversObject<{ + safeUrl?: SafeUrlDirectiveResolver; +}>; + + +/** + * @deprecated + * Use "DirectiveResolvers" root object instead. If you wish to get "IDirectiveResolvers", add "typesPrefix: I" to your config. + */ +export type IDirectiveResolvers = DirectiveResolvers; \ No newline at end of file diff --git a/app/graphql/resolvers/index.ts b/app/graphql/resolvers/index.ts index b23bf9fb3..aae8191eb 100644 --- a/app/graphql/resolvers/index.ts +++ b/app/graphql/resolvers/index.ts @@ -1,5 +1,8 @@ +import { GraphQLError, GraphQLScalarType, Kind } from "graphql"; + import { MeasureType } from "@/configurator"; import { DimensionType } from "@/domain/data"; +import { isDataSourceUrlAllowed } from "@/domain/datasource"; import { QueryResolvers, Resolvers, @@ -119,6 +122,34 @@ export const resolveMeasureType = ( return scaleType === "Ordinal" ? "OrdinalMeasure" : "NumericalMeasure"; }; +export const datasourceUrlValue = (url: string) => { + if (isDataSourceUrlAllowed(url)) { + return url; + } + throw datasourceValidationError(); +}; + +export const datasourceValidationError = () => { + return new GraphQLError( + "BAD_USER_INPUT: Provided value is not an allowed data source" + ); +}; + +const DataSourceUrlScalar = new GraphQLScalarType({ + name: "DataSourceUrl", + description: "DataSourceUrl custom scalar type", + parseValue: datasourceUrlValue, + serialize: datasourceUrlValue, + parseLiteral(ast) { + if (ast.kind === Kind.INT) { + return datasourceUrlValue(ast.value); + } + + throw datasourceValidationError(); + }, +}); + export const resolvers: Resolvers = { + DataSourceUrl: DataSourceUrlScalar, Query, }; diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 468c15cf9..540bde835 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -15,6 +15,11 @@ scalar Termset scalar ComponentTermsets scalar GeoShapes scalar SearchCube +scalar DataSourceUrl + +directive @safeUrl( + pattern: String +) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION | FIELD_DEFINITION enum DataCubePublicationStatus { DRAFT @@ -147,7 +152,7 @@ input DataCubeDimensionGeoShapesCubeFilter { type Query { dataCubeLatestIri( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! cubeFilter: DataCubeLatestIriFilter! ): String! dataCubeUnversionedIri( @@ -157,7 +162,7 @@ type Query { ): String dataCubeComponents( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! # In case of changing this variable name, or any other `cubeFilter` down below, # make sure to update GraphQL context to keep the caching-per-cube behavior working. @@ -165,36 +170,36 @@ type Query { ): DataCubeComponents! dataCubeComponentTermsets( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! cubeFilter: DataCubeTermsetFilter! ): [ComponentTermsets!]! dataCubeMetadata( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! cubeFilter: DataCubeMetadataFilter! ): DataCubeMetadata! dataCubeObservations( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! cubeFilter: DataCubeObservationFilter! ): DataCubeObservations! dataCubePreview( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! cubeFilter: DataCubePreviewFilter! ): DataCubePreview! possibleFilters( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! cubeFilter: DataCubePossibleFiltersCubeFilter! ): [PossibleFilterValue!]! searchCubes( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String query: String order: SearchCubeResultOrder @@ -204,7 +209,7 @@ type Query { ): [SearchCubeResult!]! dataCubeDimensionGeoShapes( sourceType: String! - sourceUrl: String! + sourceUrl: DataSourceUrl! locale: String! cubeFilter: DataCubeDimensionGeoShapesCubeFilter! ): GeoShapes diff --git a/app/next.config.js b/app/next.config.js index d5559cf72..8c0cc8468 100644 --- a/app/next.config.js +++ b/app/next.config.js @@ -40,15 +40,38 @@ module.exports = withPreconstruct( headers: async () => { const headers = []; + headers.push({ + source: "/:path*", + headers: [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + ], + }); + + // See https://content-security-policy.com/ & https://developers.google.com/tag-platform/security/guides/csp + if (!(process.env.DISABLE_CSP && process.env.DISABLE_CSP === "true")) { + headers[0].headers.push({ + key: "Content-Security-Policy", + value: [ + `default-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""} https://*.sentry.io https://vercel.live/ https://vercel.com https://*.googletagmanager.com`, + `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""} https://*.sentry.io https://vercel.live/ https://vercel.com https://*.googletagmanager.com`, + `style-src 'self' 'unsafe-inline'`, + `font-src 'self'`, + `form-action 'self'`, + `connect-src 'self' https://*.sentry.io https://*.vercel.app https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com wss://*.pusher.com https://*.ldbar.ch`, + `img-src 'self' https://vercel.live https://vercel.com *.pusher.com *.pusherapp.com https://*.admin.ch https://*.opendataswiss.org https://*.google-analytics.com https://*.googletagmanager.com data: blob:`, + `script-src-elem 'self' 'unsafe-inline' https://*.admin.ch https://vercel.live https://vercel.com`, + `worker-src 'self' blob: https://*.admin.ch`, + ].join("; "), + }); + } + if (process.env.PREVENT_SEARCH_BOTS === "true") { - headers.push({ - source: "/:path*", - headers: [ - { - key: "X-Robots-Tag", - value: "noindex, nofollow", - }, - ], + headers[0].headers.push({ + key: "X-Robots-Tag", + value: "noindex, nofollow", }); } diff --git a/app/package.json b/app/package.json index 6b15e5216..88e876861 100644 --- a/app/package.json +++ b/app/package.json @@ -88,7 +88,8 @@ "github-slugger": "^1.4.0", "global-agent": "^2.1.12", "graphql": "^15.5.1", - "graphql-tag": "^2.11.0", + "graphql-constraint-directive": "v2", + "graphql-tag": "^2.12.6", "graphql-tools": "^7.0.5", "html-to-image": "^1.11.11", "iframe-resizer": "^4.2.11", diff --git a/app/pages/api/graphql.ts b/app/pages/api/graphql.ts index 2d4e50a6e..1a0d5e365 100644 --- a/app/pages/api/graphql.ts +++ b/app/pages/api/graphql.ts @@ -1,7 +1,12 @@ import { ApolloServerPluginLandingPageGraphQLPlayground } from "@apollo/server-plugin-landing-page-graphql-playground"; +import { makeExecutableSchema } from "@graphql-tools/schema"; import { ApolloServer } from "apollo-server-micro"; import configureCors from "cors"; import "global-agent/bootstrap"; +import { + constraintDirective, + constraintDirectiveTypeDefs, +} from "graphql-constraint-directive"; import { NextApiRequest, NextApiResponse } from "next"; import { SentryPlugin } from "@/graphql/apollo-sentry-plugin"; @@ -19,11 +24,16 @@ export const cors = configureCors({ origin: corsOrigin, }); +const schema = makeExecutableSchema({ + typeDefs: [constraintDirectiveTypeDefs, typeDefs], + resolvers, + schemaTransforms: [constraintDirective()], +}); + setupFlamegraph(resolvers); const server = new ApolloServer({ - typeDefs, - resolvers, + schema, formatError: (err) => { console.error(err, err?.extensions?.exception?.stacktrace); return err; diff --git a/codegen.yml b/codegen.yml index b0bdca535..37c9ed458 100644 --- a/codegen.yml +++ b/codegen.yml @@ -29,6 +29,7 @@ generates: Termset: "../domain/data#Termset" ComponentTermsets: "../domain/data#ComponentTermsets" GeoShapes: "../domain/data#GeoShapes" + DataSourceUrl: "../domain/datasource#DataSourceUrl" app/graphql/resolver-types.ts: plugins: - "typescript" @@ -53,3 +54,4 @@ generates: Termset: "../domain/data#Termset" ComponentTermsets: "../domain/data#ComponentTermsets" GeoShapes: "../domain/data#GeoShapes" + DataSourceUrl: "../domain/datasource#DataSourceUrl" diff --git a/yarn.lock b/yarn.lock index 099599b90..bbacc1192 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3916,6 +3916,14 @@ lodash "4.17.21" tslib "~2.2.0" +"@graphql-tools/schema@^6.0.9": + version "6.2.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-6.2.4.tgz#cc4e9f5cab0f4ec48500e666719d99fc5042481d" + integrity sha512-rh+14lSY1q8IPbEv2J9x8UBFJ5NrDX9W5asXEUlPp+7vraLp/Tiox4GXdgyA92JhwpYco3nTf5Bo2JDMt1KnAQ== + dependencies: + "@graphql-tools/utils" "^6.2.4" + tslib "~2.0.1" + "@graphql-tools/schema@^7.0.0", "@graphql-tools/schema@^7.1.4", "@graphql-tools/schema@^7.1.5": version "7.1.5" resolved "https://registry.npmjs.org/@graphql-tools/schema/-/schema-7.1.5.tgz" @@ -3990,6 +3998,15 @@ dependencies: tslib "^2.4.0" +"@graphql-tools/utils@^6.0.9", "@graphql-tools/utils@^6.2.4": + version "6.2.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-6.2.4.tgz#38a2314d2e5e229ad4f78cca44e1199e18d55856" + integrity sha512-ybgZ9EIJE3JMOtTrTd2VcIpTXtDrn2q6eiYkeYMKRVh3K41+LZa6YnR2zKERTXqTWqhobROwLt4BZbw2O3Aeeg== + dependencies: + "@ardatan/aggregate-error" "0.0.6" + camel-case "4.1.1" + tslib "~2.0.1" + "@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.0.1", "@graphql-tools/utils@^7.1.0", "@graphql-tools/utils@^7.1.2", "@graphql-tools/utils@^7.5.0", "@graphql-tools/utils@^7.7.0", "@graphql-tools/utils@^7.7.1", "@graphql-tools/utils@^7.8.1", "@graphql-tools/utils@^7.9.0", "@graphql-tools/utils@^7.9.1": version "7.10.0" resolved "https://registry.npmjs.org/@graphql-tools/utils/-/utils-7.10.0.tgz" @@ -11404,6 +11421,14 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +camel-case@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" + integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== + dependencies: + pascal-case "^3.1.1" + tslib "^1.10.0" + camel-case@4.1.2, camel-case@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz" @@ -15644,6 +15669,15 @@ graphql-config@^3.3.0: minimatch "3.0.4" string-env-interpolation "1.0.1" +graphql-constraint-directive@v2: + version "2.3.0" + resolved "https://registry.yarnpkg.com/graphql-constraint-directive/-/graphql-constraint-directive-2.3.0.tgz#b1511511e7c62e7247dd394dc093931d65d77ff0" + integrity sha512-KIrYBvmDd3CN6SvndUa2KaW4IK8AS1BJJRMr7CKh7ShtInfu1alEQW7xbAhmFwoj0TIWIOuiMNLiOaDyLOheuQ== + dependencies: + "@graphql-tools/schema" "^6.0.9" + "@graphql-tools/utils" "^6.0.9" + validator "^13.6.0" + graphql-request@^3.3.0: version "3.4.0" resolved "https://registry.npmjs.org/graphql-request/-/graphql-request-3.4.0.tgz" @@ -20209,7 +20243,7 @@ parseurl@~1.3.3: resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -pascal-case@^3.1.2: +pascal-case@^3.1.1, pascal-case@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz" integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== @@ -24755,6 +24789,11 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validator@^13.6.0: + version "13.12.0" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.12.0.tgz#7d78e76ba85504da3fee4fd1922b385914d4b35f" + integrity sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg== + value-or-promise@1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.11.tgz#3e90299af31dd014fe843fe309cefa7c1d94b140"