diff --git a/router-tests/file_upload_test.go b/router-tests/file_upload_test.go index c271fde7ea..650f79ed9a 100644 --- a/router-tests/file_upload_test.go +++ b/router-tests/file_upload_test.go @@ -51,10 +51,10 @@ func TestSingleFileUpload_NoFileProvided(t *testing.T) { func TestFileUpload_FilesSizeExceedsLimit(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ - RouterOptions: []core.Option{core.WithRouterTrafficConfig(&config.RouterTrafficConfiguration{ - MaxUploadFiles: 1, - MaxRequestBodyBytes: 100, - MaxUploadFileSizeBytes: 50, + RouterOptions: []core.Option{core.WithFileUploadConfig(&config.FileUpload{ + Enabled: true, + MaxFiles: 1, + MaxFileSizeBytes: 50, })}, }, func(t *testing.T, xEnv *testenv.Environment) { files := make([][]byte, 1) @@ -73,10 +73,10 @@ func TestFileUpload_FilesSizeExceedsLimit(t *testing.T) { func TestFileUpload_FilesExceedsLimit(t *testing.T) { t.Parallel() testenv.Run(t, &testenv.Config{ - RouterOptions: []core.Option{core.WithRouterTrafficConfig(&config.RouterTrafficConfiguration{ - MaxUploadFiles: 2, - MaxRequestBodyBytes: 50000, - MaxUploadFileSizeBytes: 50000, + RouterOptions: []core.Option{core.WithFileUploadConfig(&config.FileUpload{ + Enabled: true, + MaxFiles: 2, + MaxFileSizeBytes: 50000, })}, }, func(t *testing.T, xEnv *testenv.Environment) { files := make([][]byte, 3) @@ -129,3 +129,20 @@ func TestMultipleFilesUpload_NoFilesProvided(t *testing.T) { require.JSONEq(t, `{"errors":[{"message":"Failed to fetch from Subgraph '0' at Path 'mutation'.","extensions":{"errors":[{"message":"could not render fetch input","path":[]}]}},{"message":"Cannot return null for non-nullable field 'Mutation.multipleUpload'.","path":["multipleUpload"]}],"data":null}`, res.Body) }) } + +func TestFileUpload_UploadDisabled(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{core.WithFileUploadConfig(&config.FileUpload{ + Enabled: false, + })}, + }, func(t *testing.T, xEnv *testenv.Environment) { + files := make([][]byte, 1) + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: "mutation($files: [Upload!]!) { multipleUpload(files: $files)}", + Variables: []byte(`{"files":[null]}`), + Files: files, + }) + require.Equal(t, `{"errors":[{"message":"file upload disabled"}],"data":null}`, res.Body) + }) +} diff --git a/router/cmd/instance.go b/router/cmd/instance.go index 823505df25..4c927151fc 100644 --- a/router/cmd/instance.go +++ b/router/cmd/instance.go @@ -119,6 +119,7 @@ func NewRouter(params Params, additionalOptions ...core.Option) (*core.Router, e core.WithHeaderRules(cfg.Headers), core.WithStaticRouterConfig(routerConfig), core.WithRouterTrafficConfig(&cfg.TrafficShaping.Router), + core.WithFileUploadConfig(&cfg.FileUpload), core.WithSubgraphTransportOptions(&core.SubgraphTransportOptions{ RequestTimeout: cfg.TrafficShaping.All.RequestTimeout, ResponseHeaderTimeout: cfg.TrafficShaping.All.ResponseHeaderTimeout, diff --git a/router/core/graphql_prehandler.go b/router/core/graphql_prehandler.go index 0edcc7eda1..b83ce9a7b5 100644 --- a/router/core/graphql_prehandler.go +++ b/router/core/graphql_prehandler.go @@ -47,6 +47,7 @@ type PreHandlerOptions struct { FlushTelemetryAfterResponse bool TraceExportVariables bool SpanAttributesMapper func(r *http.Request) []attribute.KeyValue + FileUploadEnabled bool MaxUploadFiles int MaxUploadFileSize int } @@ -67,6 +68,7 @@ type PreHandler struct { tracer trace.Tracer traceExportVariables bool spanAttributesMapper func(r *http.Request) []attribute.KeyValue + fileUploadEnabled bool maxUploadFiles int maxUploadFileSize int } @@ -91,6 +93,7 @@ func NewPreHandler(opts *PreHandlerOptions) *PreHandler { "wundergraph/cosmo/router/pre_handler", trace.WithInstrumentationVersion("0.0.1"), ), + fileUploadEnabled: opts.FileUploadEnabled, maxUploadFiles: opts.MaxUploadFiles, maxUploadFileSize: opts.MaxUploadFileSize, } @@ -177,12 +180,12 @@ func (h *PreHandler) Handler(next http.Handler) http.Handler { var body []byte var files []httpclient.File - var err error // XXX: This buffer needs to be returned to the pool only // AFTER we're done with body (retrieved from parser.ReadBody()) buf := pool.GetBytesBuffer() defer pool.PutBytesBuffer(buf) if r.Header.Get("Content-Type") == "" || r.Header.Get("Content-Type") == "application/json" { + var err error body, err = h.operationProcessor.ReadBody(buf, r.Body) if err != nil { finalErr = err @@ -199,8 +202,18 @@ func (h *PreHandler) Handler(next http.Handler) http.Handler { return } } else if strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { + if !h.fileUploadEnabled { + finalErr = &inputError{ + message: "file upload disabled", + statusCode: http.StatusOK, + } + writeOperationError(r, w, requestLogger, finalErr) + return + } + multipartParser := NewMultipartParser(h.operationProcessor, h.maxUploadFiles, h.maxUploadFileSize) + var err error body, files, err = multipartParser.Parse(r, buf) if err != nil { finalErr = err diff --git a/router/core/router.go b/router/core/router.go index 91343a65fe..85ef93279f 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -181,6 +181,7 @@ type ( subgraphTransportOptions *SubgraphTransportOptions graphqlMetricsConfig *GraphQLMetricsConfig routerTrafficConfig *config.RouterTrafficConfiguration + fileUploadConfig *config.FileUpload accessController *AccessController retryOptions retrytransport.RetryOptions redisClient *redis.Client @@ -303,6 +304,9 @@ func NewRouter(opts ...Option) (*Router, error) { if r.routerTrafficConfig == nil { r.routerTrafficConfig = DefaultRouterTrafficConfig() } + if r.fileUploadConfig == nil { + r.fileUploadConfig = DefaultFileUploadConfig() + } if r.accessController == nil { r.accessController = DefaultAccessController() } else { @@ -1342,8 +1346,9 @@ func (r *Router) newServer(ctx context.Context, routerConfig *nodev1.RouterConfi FlushTelemetryAfterResponse: r.awsLambda, TraceExportVariables: r.traceConfig.ExportGraphQLVariables.Enabled, SpanAttributesMapper: r.traceConfig.SpanAttributesMapper, - MaxUploadFiles: r.routerTrafficConfig.MaxUploadFiles, - MaxUploadFileSize: int(r.routerTrafficConfig.MaxUploadFileSizeBytes), + FileUploadEnabled: r.fileUploadConfig.Enabled, + MaxUploadFiles: r.fileUploadConfig.MaxFiles, + MaxUploadFileSize: int(r.fileUploadConfig.MaxFileSizeBytes), }) graphqlChiRouter := chi.NewRouter() @@ -1820,6 +1825,12 @@ func WithRouterTrafficConfig(cfg *config.RouterTrafficConfiguration) Option { } } +func WithFileUploadConfig(cfg *config.FileUpload) Option { + return func(r *Router) { + r.fileUploadConfig = cfg + } +} + func WithAccessController(controller *AccessController) Option { return func(r *Router) { r.accessController = controller @@ -1846,9 +1857,15 @@ func WithLocalhostFallbackInsideDocker(fallback bool) Option { func DefaultRouterTrafficConfig() *config.RouterTrafficConfiguration { return &config.RouterTrafficConfiguration{ - MaxRequestBodyBytes: 1000 * 1000 * 5, // 5 MB - MaxUploadFileSizeBytes: 1000 * 1000 * 50, // 50 MB, - MaxUploadFiles: 10, + MaxRequestBodyBytes: 1000 * 1000 * 5, // 5 MB + } +} + +func DefaultFileUploadConfig() *config.FileUpload { + return &config.FileUpload{ + Enabled: true, + MaxFileSizeBytes: 1000 * 1000 * 50, // 50 MB, + MaxFiles: 10, } } diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 74473ec2a1..f4b1f00a79 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -124,11 +124,15 @@ type TrafficShapingRules struct { Router RouterTrafficConfiguration `yaml:"router"` } +type FileUpload struct { + Enabled bool `yaml:"enabled" default:"true" envconfig:"FILE_UPLOAD_ENABLED"` + MaxFileSizeBytes BytesString `yaml:"max_file_size" default:"50MB" envconfig:"FILE_UPLOAD_MAX_FILE_SIZE"` + MaxFiles int `yaml:"max_files" default:"10" envconfig:"FILE_UPLOAD_MAX_FILES"` +} + type RouterTrafficConfiguration struct { // MaxRequestBodyBytes is the maximum size of the request body in bytes - MaxRequestBodyBytes BytesString `yaml:"max_request_body_size" default:"5MB"` - MaxUploadFileSizeBytes BytesString `yaml:"max_upload_file_size" default:"50MB"` - MaxUploadFiles int `yaml:"max_upload_files" default:"10"` + MaxRequestBodyBytes BytesString `yaml:"max_request_body_size" default:"5MB"` } type GlobalSubgraphRequestRule struct { @@ -426,6 +430,7 @@ type Config struct { Modules map[string]interface{} `yaml:"modules,omitempty"` Headers HeaderRules `yaml:"headers,omitempty"` TrafficShaping TrafficShapingRules `yaml:"traffic_shaping,omitempty"` + FileUpload FileUpload `yaml:"file_upload,omitempty"` ListenAddr string `yaml:"listen_addr" default:"localhost:3002" envconfig:"LISTEN_ADDR"` ControlplaneURL string `yaml:"controlplane_url" default:"https://cosmo-cp.wundergraph.com" envconfig:"CONTROLPLANE_URL"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index e638844d98..f49fcc96e2 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -624,6 +624,30 @@ "default": "/", "description": "The path of the GraphQL Playground. The GraphQL Playground is a web-based GraphQL IDE that allows you to interact with the GraphQL API. The default value is '/'." }, + "file_upload": { + "type": "object", + "description": "The configuration for file upload. Configure whether it should be enabled along with file size and number of files.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "max_file_size": { + "type": "string", + "bytes": { + "minimum": "1MB" + }, + "description": "The maximum size of a file that can be uploaded. The size is specified as a string with a number and a unit, e.g. 10KB, 1MB, 1GB. The supported units are 'KB', 'MB', 'GB'." + }, + "max_files": { + "type": "integer", + "default": 10, + "minimum": 1, + "description": "The maximum number of files that can be uploaded." + } + } + }, "traffic_shaping": { "type": "object", "description": "The configuration for the traffic shaping. Configure rules for traffic shaping like maximum request body size, timeouts, retry behavior, etc. See https://cosmo-docs.wundergraph.com/router/traffic-shaping for more information.", @@ -639,19 +663,6 @@ "minimum": "1MB" }, "description": "The maximum request body size. The size is specified as a string with a number and a unit, e.g. 10KB, 1MB, 1GB. The supported units are 'KB', 'MB', 'GB'." - }, - "max_upload_file_size": { - "type": "string", - "bytes": { - "minimum": "1MB" - }, - "description": "The maximum size of a file that can be uploaded. The size is specified as a string with a number and a unit, e.g. 10KB, 1MB, 1GB. The supported units are 'KB', 'MB', 'GB'." - }, - "max_upload_files": { - "type": "integer", - "default": 10, - "minimum": 1, - "description": "The maximum number of files that can be uploaded." } } }, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 0d0eea722d..75f392928d 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -104,11 +104,14 @@ "KeepAliveProbeInterval": 30000000000 }, "Router": { - "MaxRequestBodyBytes": 5000000, - "MaxUploadFileSizeBytes": 50000000, - "MaxUploadFiles": 10 + "MaxRequestBodyBytes": 5000000 } }, + "FileUpload": { + "Enabled": true, + "MaxFileSizeBytes": 50000000, + "MaxFiles": 10 + }, "ListenAddr": "localhost:3002", "ControlplaneURL": "https://cosmo-cp.wundergraph.com", "PlaygroundEnabled": true, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index baf1a3a8d0..fcd1717c4d 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -160,11 +160,14 @@ "KeepAliveProbeInterval": 30000000000 }, "Router": { - "MaxRequestBodyBytes": 5000000, - "MaxUploadFileSizeBytes": 50000000, - "MaxUploadFiles": 10 + "MaxRequestBodyBytes": 5000000 } }, + "FileUpload": { + "Enabled": true, + "MaxFileSizeBytes": 50000000, + "MaxFiles": 10 + }, "ListenAddr": "localhost:3002", "ControlplaneURL": "https://cosmo-cp.wundergraph.com", "PlaygroundEnabled": true,