GraphQL联邦文件上传遇CSRF拦截问题求助
Apollo联邦文件上传CSRF拦截问题
我尝试将图片上传至Cloudflare R2存储桶,客户端上传的文件能成功抵达Gateway,但无法传递到子图,开发者工具中出现如下CSRF拦截错误:
{ "errors": [ { "message": "This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty value for one of the following headers: x-apollo-operation-name, apollo-require-preflight", "extensions": { "code": "BAD_REQUEST", "stacktrace": [ "GraphQLError: This operation has been blocked as a potential Cross-Site Request Forgery (CSRF). Please either specify a 'content-type' header (with a type that is not one of application/x-www-form-urlencoded, multipart/form-data, text/plain) or provide a non-empty value for one of the following headers: x-apollo-operation-name, apollo-require-preflight", "" ], "serviceName": "userService" } } ], "data": { "uploadUserPhoto": null } }
我已经在客户端请求头中添加了apollo-require-preflight:true,但不确定是否需要在Gateway与子图之间也添加该请求头。以下是相关代码实现:
客户端代码
上传组件
import { gql, useMutation } from '@apollo/client'; const UPLOAD = gql` mutation UploadUserPhoto($photo: Upload) { uploadUserPhoto(photo: $photo) } `; function FileUploadSingle() { const [upload] = useMutation(UPLOAD); const handleFileUpload = async (event: any) => { const file = event.target.files[0]; try { await upload({ variables: { photo: file, }, }); } catch (error) { console.error(error); } }; return ( <div> <input type="file" onChange={(e: any) => handleFileUpload(e)} /> </div> ); } export default FileUploadSingle;
index.tsx
import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { createUploadLink } from 'apollo-upload-client'; import { ApolloClient, InMemoryCache, ApolloProvider, from, } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; const uploadLink = createUploadLink({ uri: 'http://localhost:4000/graphql', headers: { 'apollo-require-preflight': 'true' }, }); const authLink = setContext((_, { headers }) => { const token = localStorage.getItem('token'); return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '', }, }; }); const client = new ApolloClient({ link: from([authLink, uploadLink]), cache: new InMemoryCache(), }); const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement ); root.render( <React.StrictMode> <ApolloProvider client={client}> <App /> </ApolloProvider> </React.StrictMode> );
服务端代码
Gateway / index.ts
import { getOriginsAccordingToEnvironment } from "./helpers"; import { ApolloGateway, IntrospectAndCompose } from "@apollo/gateway"; import { ApolloServer } from "@apollo/server"; import { expressMiddleware } from "@apollo/server/express4"; import express from "express"; import { json } from "body-parser"; import cors from "cors"; import { ENVIRONMENTS } from "./constants"; import { verifyToken } from "./helpers"; import graphqlUploadExpress from "graphql-upload/graphqlUploadExpress.js"; import waitOn from "wait-on"; require("dotenv").config(); const app = express(); import FileUploadDataSource from "@profusion/apollo-federation-upload"; const startServer = async () => { const environment = process.env.NODE_ENV as string; let gateway; if (environment === ENVIRONMENTS.DEVELOPMENT) { gateway = new ApolloGateway({ supergraphSdl: new IntrospectAndCompose({ subgraphs: [ { name: "userService", url: process.env.USER_SERVICE_URL }, { name: "paymentService", url: process.env.PAYMENT_SERVICE_URL }, { name: "productService", url: process.env.PRODUCT_SERVICE_URL }, ], }), buildService({ name, url }) { return new FileUploadDataSource({ url, useChunkedTransfer: true, willSendRequest({ request, context, }: { request: any; context: any }) { console.log(request.variables) // 这里能获取到上传文件的信息 request.http.headers.set( "user", context.id && context.fullName && context.email && context.isAuth ? JSON.stringify(context) : null ); }, }); }, }); } const server = new ApolloServer({ gateway, }); await server.start(); app.use(graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 })); app.use( "/graphql", cors({ origin: getOriginsAccordingToEnvironment(environment), }), json(), expressMiddleware(server, { context: async ({ req }: { req: any }) => { interface JwtPayload { id: string; iat: number; exp: number; } const verifiedToken = verifyToken( req.headers.authorization ) as JwtPayload; const user = verifiedToken || null; return user; }, }) ); app.listen({ port: 4000 }, () => { console.log(`🚀 Server ready at http://localhost:4000/graphql`); }); }; const startGateWayWithAllServices = () => { const waitOnOptions = { resources: ["tcp:4001", "tcp:4002", "tcp:4003"], }; waitOn(waitOnOptions) .then(() => { startServer(); }) .catch((err) => { console.error("ERR:", err); }); }; startGateWayWithAllServices();
User Subgraph / resolver.ts
import UserController from "../../controllers/user"; import { IUser } from "../../types"; import { uploadToStorage } from "../../helpers/image-upload"; import { nanoid } from "nanoid"; const resolvers = { Mutation: { async uploadUserPhoto(_: any, { photo }: { photo: any }, ctx: any) { if (!ctx || !ctx.id || !ctx.isAuth) { throw new Error("You have to login!"); } const uploadedImage = await uploadToStorage({ filename: `${ctx.id}`, file: photo, }); await UserController.updateUserAvatarById({ userId: ctx.id, avatarURL: uploadedImage.Location, }); }, }, User: { async __resolveReference(user: any) { return await UserController.findUserById({ id: user.id }); }, }, }; export { resolvers };
User Subgraph / index.ts
require("dotenv").config(); import { ApolloServer } from "@apollo/server"; import { buildSubgraphSchema } from "@apollo/subgraph"; import gql from "graphql-tag"; import { resolvers } from "./graphql/resolvers/user"; import { startStandaloneServer } from "@apollo/server/standalone"; import { readFileSync } from "fs"; const mongoose = require("mongoose"); const typeDefs = gql( readFileSync(`${__dirname}/graphql/types/user.graphql`).toString("utf-8") ); import { DB_URI } from "../database-config"; const createDatabaseConnection = async () => { try { mongoose.set("strictQuery", false); await mongoose.connect(DB_URI); console.log("✅ User Service: Connected to DB"); } catch (error) { console.log("⛔", error); } }; const startUserServiceServer = async () => { const server = new ApolloServer({ schema: buildSubgraphSchema({ typeDefs, resolvers }), }); await createDatabaseConnection(); const { url } = await startStandaloneServer(server, { context: async ({ req }: { req: any }) => { const user = req.headers.user ? JSON.parse(req.headers.user) : null; return user; }, listen: { port: 4001 }, }); console.log(`🚀 User Service: Server ready at ${url}`); }; startUserServiceServer();
解决方案
问题出在Gateway转发请求到子图时,没有携带apollo-require-preflight或x-apollo-operation-name头,导致子图的Apollo Server触发了CSRF拦截。
你需要在Gateway的willSendRequest钩子中,将客户端传来的apollo-require-preflight头转发给子图,或者直接添加该头:
修改Gateway的buildService部分代码:
buildService({ name, url }) { return new FileUploadDataSource({ url, useChunkedTransfer: true, willSendRequest({ request, context, requestContext }: { request: any; context: any; requestContext: any }) { console.log(request.variables) // 这里能获取到上传文件的信息 // 转发客户端的apollo-require-preflight头 const preflightHeader = requestContext.req.headers['apollo-require-preflight']; if (preflightHeader) { request.http.headers.set('apollo-require-preflight', preflightHeader); } // 或者直接设置该头 // request.http.headers.set('apollo-require-preflight', 'true'); request.http.headers.set( "user", context.id && context.fullName && context.email && context.isAuth ? JSON.stringify(context) : null ); }, }); }
另外,也可以选择添加x-apollo-operation-name头,值为当前执行的GraphQL操作名称(比如UploadUserPhoto),同样能绕过CSRF检查。
修改后,Gateway转发给子图的请求就会包含所需的头,子图的Apollo Server就不会拦截该请求了。
内容的提问来源于stack exchange,提问作者Ertan Özdemir
相关产品推荐
相关产品推荐

