Skip to content

Commit

Permalink
fix(server): return HTTP 403 errors when rejecting disallowed queries
Browse files Browse the repository at this point in the history
replaces apollo-server-plugin with express middleware since errors throw
within are unable to be caught there too.
  • Loading branch information
rhyslbw committed Aug 3, 2020
1 parent eeeafa9 commit b8892ca
Show file tree
Hide file tree
Showing 13 changed files with 170 additions and 151 deletions.
2 changes: 2 additions & 0 deletions packages/server/package.json
Expand Up @@ -31,8 +31,10 @@
"apollo-server-core": "^2.14.3",
"apollo-server-errors": "^2.4.1",
"apollo-server-express": "^2.14.3",
"body-parser": "^1.19.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"fs-extra": "^9.0.1",
"graphql": "14.5.8",
"graphql-depth-limit": "^1.1.0",
"prom-client": "^11.5.3",
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/AllowList.ts
@@ -0,0 +1 @@
export type AllowList = {[key: string]: number }
23 changes: 23 additions & 0 deletions packages/server/src/CompleteApiServer.ts
@@ -0,0 +1,23 @@
import { Config } from './config'
import { Server } from './Server'
import { buildSchema as buildCardanoDbHasuraSchema, Db } from '@cardano-graphql/api-cardano-db-hasura'
import { buildSchema as buildGenesisSchema } from '@cardano-graphql/api-genesis'
import { GraphQLSchema } from 'graphql'

export * from './config'

export async function CompleteApiServer (config: Config): Promise<Server> {
const schemas: GraphQLSchema[] = []
if (config.genesisFileByron !== undefined || config.genesisFileShelley !== undefined) {
schemas.push(buildGenesisSchema({
...config.genesisFileByron !== undefined ? { byron: require(config.genesisFileByron) } : {},
...config.genesisFileShelley !== undefined ? { shelley: require(config.genesisFileShelley) } : {}
}))
}
if (config.hasuraUri !== undefined) {
const db = new Db(config.hasuraUri)
await db.init()
schemas.push(await buildCardanoDbHasuraSchema(config.hasuraUri, db))
}
return new Server(schemas, config)
}
105 changes: 91 additions & 14 deletions packages/server/src/Server.ts
@@ -1,17 +1,94 @@
import { ApolloServer, ApolloServerExpressConfig } from 'apollo-server-express'
import { ApolloServer, CorsOptions } from 'apollo-server-express'
import express from 'express'
import corsMiddleware from 'cors'
import { GraphQLSchema } from 'graphql'
import { mergeSchemas } from '@graphql-tools/merge'
import { prometheusMetricsPlugin } from './apollo_server_plugins'
import { allowListMiddleware } from './express_middleware'
import depthLimit from 'graphql-depth-limit'
import { PluginDefinition } from 'apollo-server-core'
import { AllowList } from './AllowList'
import { json } from 'body-parser'
import fs from 'fs-extra'
import { listenPromise } from './util'
import http from 'http'

export function Server (
app: express.Application,
apolloServerExpressConfig: ApolloServerExpressConfig,
cors?: corsMiddleware.CorsOptions
): ApolloServer {
const apolloServer = new ApolloServer(apolloServerExpressConfig)
apolloServer.applyMiddleware({
app,
cors,
path: '/'
})
return apolloServer
export type Config = {
allowIntrospection?: boolean
allowListPath?: string
allowedOrigins?: CorsOptions['origin']
apiPort: number
cacheEnabled?: boolean
prometheusMetrics?: boolean
queryDepthLimit?: number
tracing?: boolean
}

export class Server {
public app: express.Application
private apolloServer: ApolloServer
private config: Config
private httpServer: http.Server
private schemas: GraphQLSchema[]

constructor (schemas: GraphQLSchema[], config?: Config) {
this.app = express()
this.config = config
this.schemas = schemas
}

async init () {
let allowList: AllowList
const plugins: PluginDefinition[] = []
const validationRules = []
if (this.config?.allowListPath) {
try {
const file = await fs.readFile(this.config.allowListPath, 'utf8')
allowList = JSON.parse(file)
this.app.use('/', json(), allowListMiddleware(allowList))
console.log('The server will only allow only operations from the provided list')
} catch (error) {
console.error(`Cannot read or parse allow-list JSON file at ${this.config.allowListPath}`)
throw error
}
}
if (this.config?.prometheusMetrics) {
plugins.push(prometheusMetricsPlugin(this.app))
console.log('Prometheus metrics will be served at /metrics')
}
if (this.config?.queryDepthLimit) {
validationRules.push(depthLimit(this.config?.queryDepthLimit))
}
this.apolloServer = new ApolloServer({
cacheControl: this.config?.cacheEnabled ? { defaultMaxAge: 20 } : undefined,
introspection: !!this.config?.allowIntrospection,
playground: !!this.config?.allowIntrospection,
plugins,
validationRules,
schema: mergeSchemas({
schemas: this.schemas
})
})
this.apolloServer.applyMiddleware({
app: this.app,
cors: this.config?.allowedOrigins ? {
origin: this.config?.allowedOrigins
} : undefined,
path: '/'
})
}

async start () {
this.httpServer = await listenPromise(this.app, { port: this.config.apiPort })
console.log(
`GraphQL HTTP server at http://localhost:${this.config.apiPort}${this.apolloServer.graphqlPath}`
)
}

shutdown () {
this.httpServer.close()
console.log(
`GraphQL HTTP server at http://localhost:${this.config.apiPort}${this.apolloServer.graphqlPath}
shutting down`
)
}
}
19 changes: 0 additions & 19 deletions packages/server/src/apollo_server_plugins/allow_list_plugin.ts

This file was deleted.

1 change: 0 additions & 1 deletion packages/server/src/apollo_server_plugins/index.ts
@@ -1,2 +1 @@
export * from './prometheus_metrics_plugin'
export * from './allow_list_plugin'
17 changes: 2 additions & 15 deletions packages/server/src/config.ts
@@ -1,19 +1,10 @@
import { CorsOptions } from 'apollo-server-express'
import { IntrospectionNotPermitted, MissingConfig, TracingRequired } from './errors'
import { Config as ServerConfig } from './Server'

export type Config = {
allowIntrospection?: boolean
allowedOrigins: CorsOptions['origin']
allowListPath: string
apiPort: number
cacheEnabled: boolean
export type Config = ServerConfig & {
genesisFileByron: string
genesisFileShelley: string
hasuraUri: string
poolMetadataProxy: string
prometheusMetrics: boolean
queryDepthLimit: number
tracing: boolean
}

export async function getConfig (): Promise<Config> {
Expand All @@ -26,7 +17,6 @@ export async function getConfig (): Promise<Config> {
genesisFileByron,
genesisFileShelley,
hasuraUri,
poolMetadataProxy,
prometheusMetrics,
queryDepthLimit,
tracing
Expand All @@ -50,7 +40,6 @@ export async function getConfig (): Promise<Config> {
genesisFileByron,
genesisFileShelley,
hasuraUri,
poolMetadataProxy,
prometheusMetrics,
queryDepthLimit: queryDepthLimit || 10,
tracing
Expand All @@ -67,7 +56,6 @@ function filterAndTypecastEnvs (env: any) {
GENESIS_FILE_BYRON,
GENESIS_FILE_SHELLEY,
HASURA_URI,
POOL_METADATA_PROXY,
PROMETHEUS_METRICS,
QUERY_DEPTH_LIMIT,
TRACING,
Expand All @@ -86,7 +74,6 @@ function filterAndTypecastEnvs (env: any) {
genesisFileByron: GENESIS_FILE_BYRON,
genesisFileShelley: GENESIS_FILE_SHELLEY,
hasuraUri: HASURA_URI,
poolMetadataProxy: POOL_METADATA_PROXY,
prometheusMetrics: PROMETHEUS_METRICS === 'true' ? true : undefined,
queryDepthLimit: Number(QUERY_DEPTH_LIMIT),
tracing: TRACING === 'true' ? true : undefined
Expand Down
10 changes: 10 additions & 0 deletions packages/server/src/express_middleware/allow_list.ts
@@ -0,0 +1,10 @@
import express from 'express'

export function allowListMiddleware (allowList: {[key: string]: number }) {
return (request: express.Request, response: express.Response, next: Function) => {
if (allowList[request.body.query] === undefined) {
response.sendStatus(403)
}
next()
}
}
1 change: 1 addition & 0 deletions packages/server/src/express_middleware/index.ts
@@ -0,0 +1 @@
export * from './allow_list'
77 changes: 8 additions & 69 deletions packages/server/src/index.ts
@@ -1,76 +1,15 @@
import * as apolloServerPlugins from './apollo_server_plugins'
import express from 'express'
import fs from 'fs'
import { getConfig } from './config'
import { Server } from './Server'
import depthLimit from 'graphql-depth-limit'
import { mergeSchemas } from '@graphql-tools/merge'
import { PluginDefinition } from 'apollo-server-core'
import { buildSchema as buildCardanoDbHasuraSchema, Db } from '@cardano-graphql/api-cardano-db-hasura'
import { buildSchema as buildGenesisSchema } from '@cardano-graphql/api-genesis'
import { GraphQLSchema } from 'graphql'
export * from './config'
export { apolloServerPlugins }

const { prometheusMetricsPlugin, allowListPlugin } = apolloServerPlugins

async function boot () {
const config = await getConfig()
const schemas: GraphQLSchema[] = []
const validationRules = []
const plugins: PluginDefinition[] = []
const app = express()

if (config.genesisFileByron !== undefined || config.genesisFileShelley !== undefined) {
schemas.push(buildGenesisSchema({
...config.genesisFileByron !== undefined ? { byron: require(config.genesisFileByron) } : {},
...config.genesisFileShelley !== undefined ? { shelley: require(config.genesisFileShelley) } : {}
}))
}

if (config.hasuraUri !== undefined) {
const db = new Db(config.hasuraUri)
await db.init()
schemas.push(await buildCardanoDbHasuraSchema(config.hasuraUri, db))
}
import { CompleteApiServer } from './CompleteApiServer'

if (config.prometheusMetrics) {
plugins.push(prometheusMetricsPlugin(app))
}
if (config.allowListPath) {
const allowList = JSON.parse(fs.readFileSync(config.allowListPath, 'utf8'))
plugins.push(allowListPlugin(allowList))
}
if (config.queryDepthLimit) {
validationRules.push(depthLimit(config.queryDepthLimit))
}
const server = Server(app, {
cacheControl: config.cacheEnabled ? { defaultMaxAge: 20 } : undefined,
introspection: config.allowIntrospection,
playground: config.allowIntrospection,
plugins,
validationRules,
schema: mergeSchemas({
schemas
})
}, {
origin: config.allowedOrigins
})
export * from './config'

(async function () {
try {
app.listen({ port: config.apiPort }, () => {
const serverUri = `http://localhost:${config.apiPort}`
console.log(`GraphQL HTTP server at ${serverUri}${server.graphqlPath}`)
if (process.env.NODE_ENV !== 'production' && config.allowListPath) {
console.warn('As an allow-list is in effect, the GraphQL Playground is available, but will not allow schema exploration')
}
if (config.prometheusMetrics) {
console.log(`Prometheus metrics at ${serverUri}/metrics`)
}
})
const server = await CompleteApiServer(await getConfig())
await server.init()
await server.start()
} catch (error) {
console.error(error.message)
process.exit(1)
}
}

boot()
})()
9 changes: 9 additions & 0 deletions packages/server/src/util.ts
@@ -0,0 +1,9 @@
import { Application } from 'express'
import http from 'http'

export function listenPromise (app: Application, config: { port: number }): Promise<http.Server> {
return new Promise(function (resolve, reject) {
const server: http.Server = app.listen(config.port, () => resolve(server))
server.on('error', reject)
})
}

0 comments on commit b8892ca

Please sign in to comment.