Skip to content

Commit 2a5425f

Browse files
authored
fix(grpc-web): response contains two trailer chunks (#11988)
1 parent 2d524fe commit 2a5425f

File tree

2 files changed

+94
-73
lines changed

2 files changed

+94
-73
lines changed

apisix/plugins/grpc-web.lua

+69-47
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,46 @@ function _M.check_schema(conf)
6868
return core.schema.check(schema, conf)
6969
end
7070

71+
local function exit(ctx, status)
72+
ctx.grpc_web_skip_body_filter = true
73+
return status
74+
end
75+
76+
--- Build gRPC-Web trailer chunk
77+
-- grpc-web trailer format reference:
78+
-- envoyproxy/envoy/source/extensions/filters/http/grpc_web/grpc_web_filter.cc
79+
--
80+
-- Format for grpc-web trailer
81+
-- 1 byte: 0x80
82+
-- 4 bytes: length of the trailer
83+
-- n bytes: trailer
84+
-- It using upstream_trailer_* variables from nginx, it is available since NGINX version 1.13.10
85+
-- https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_upstream_trailer_
86+
--
87+
-- @param grpc_status number grpc status code
88+
-- @param grpc_message string grpc message
89+
-- @return string grpc-web trailer chunk in raw string
90+
local build_trailer = function (grpc_status, grpc_message)
91+
local status_str = "grpc-status:" .. grpc_status
92+
local status_msg = "grpc-message:" .. ( grpc_message or "")
93+
local grpc_web_trailer = status_str .. "\r\n" .. status_msg .. "\r\n"
94+
local len = #grpc_web_trailer
95+
96+
-- 1 byte: 0x80
97+
local trailer_buf = string.char(0x80)
98+
-- 4 bytes: length of the trailer
99+
trailer_buf = trailer_buf .. string.char(
100+
bit.band(bit.rshift(len, 24), 0xff),
101+
bit.band(bit.rshift(len, 16), 0xff),
102+
bit.band(bit.rshift(len, 8), 0xff),
103+
bit.band(len, 0xff)
104+
)
105+
-- n bytes: trailer
106+
trailer_buf = trailer_buf .. grpc_web_trailer
107+
108+
return trailer_buf
109+
end
110+
71111
function _M.access(conf, ctx)
72112
-- set context variable mime
73113
-- When processing non gRPC Web requests, `mime` can be obtained in the context
@@ -76,19 +116,19 @@ function _M.access(conf, ctx)
76116

77117
local method = core.request.get_method()
78118
if method == ALLOW_METHOD_OPTIONS then
79-
return 204
119+
return exit(ctx, 204)
80120
end
81121

82122
if method ~= ALLOW_METHOD_POST then
83123
-- https://github.com/grpc/grpc-web/blob/master/doc/browser-features.md#cors-support
84124
core.log.error("request method: `", method, "` invalid")
85-
return 400
125+
return exit(ctx, 405)
86126
end
87127

88128
local encoding = grpc_web_content_encoding[ctx.grpc_web_mime]
89129
if not encoding then
90130
core.log.error("request Content-Type: `", ctx.grpc_web_mime, "` invalid")
91-
return 400
131+
return exit(ctx, 400)
92132
end
93133

94134
-- set context variable encoding method
@@ -98,7 +138,7 @@ function _M.access(conf, ctx)
98138
if not (ctx.curr_req_matched and ctx.curr_req_matched[":ext"]) then
99139
core.log.error("routing configuration error, grpc-web plugin only supports ",
100140
"`prefix matching` pattern routing")
101-
return 400
141+
return exit(ctx, 400)
102142
end
103143

104144
local path = ctx.curr_req_matched[":ext"]
@@ -110,16 +150,17 @@ function _M.access(conf, ctx)
110150

111151
-- set grpc body
112152
local body, err = core.request.get_body()
113-
if err then
153+
if err or not body then
114154
core.log.error("failed to read request body, err: ", err)
115-
return 400
155+
return exit(ctx, 400)
116156
end
117157

118158
if encoding == CONTENT_ENCODING_BASE64 then
119159
body = decode_base64(body)
160+
ngx.log(ngx.WARN, "DECODE BODY: ", body)
120161
if not body then
121162
core.log.error("failed to decode request body")
122-
return 400
163+
return exit(ctx, 400)
123164
end
124165
end
125166

@@ -139,11 +180,19 @@ function _M.header_filter(conf, ctx)
139180
if not ctx.cors_allow_origins then
140181
core.response.set_header("Access-Control-Allow-Origin", DEFAULT_CORS_ALLOW_ORIGIN)
141182
end
142-
core.response.set_header("Content-Type", ctx.grpc_web_mime)
143183
core.response.set_header("Access-Control-Expose-Headers", DEFAULT_CORS_EXPOSE_HEADERS)
184+
185+
if not ctx.grpc_web_skip_body_filter then
186+
core.response.set_header("Content-Type", ctx.grpc_web_mime)
187+
core.response.set_header("Content-Length", nil)
188+
end
144189
end
145190

146191
function _M.body_filter(conf, ctx)
192+
if ctx.grpc_web_skip_body_filter then
193+
return
194+
end
195+
147196
-- If the MIME extension type description of the gRPC-Web standard is not obtained,
148197
-- indicating that the request is not based on the gRPC Web specification,
149198
-- the processing of the request body will be ignored
@@ -159,48 +208,21 @@ function _M.body_filter(conf, ctx)
159208
ngx_arg[1] = chunk
160209
end
161210

162-
--[[
163-
upstream_trailer_* available since NGINX version 1.13.10 :
164-
https://nginx.org/en/docs/http/ngx_http_upstream_module.html#var_upstream_trailer_
165-
166-
grpc-web trailer format reference:
167-
envoyproxy/envoy/source/extensions/filters/http/grpc_web/grpc_web_filter.cc
168-
169-
Format for grpc-web trailer
170-
1 byte: 0x80
171-
4 bytes: length of the trailer
172-
n bytes: trailer
173-
174-
--]]
175-
local status = ctx.var.upstream_trailer_grpc_status
176-
local message = ctx.var.upstream_trailer_grpc_message
177-
if status ~= "" and status ~= nil then
178-
local status_str = "grpc-status:" .. status
179-
local status_msg = "grpc-message:" .. ( message or "")
180-
local grpc_web_trailer = status_str .. "\r\n" .. status_msg .. "\r\n"
181-
local len = #grpc_web_trailer
182-
183-
-- 1 byte: 0x80
184-
local trailer_buf = string.char(0x80)
185-
-- 4 bytes: length of the trailer
186-
trailer_buf = trailer_buf .. string.char(
187-
bit.band(bit.rshift(len, 24), 0xff),
188-
bit.band(bit.rshift(len, 16), 0xff),
189-
bit.band(bit.rshift(len, 8), 0xff),
190-
bit.band(len, 0xff)
191-
)
192-
-- n bytes: trailer
193-
trailer_buf = trailer_buf .. grpc_web_trailer
211+
if ngx_arg[2] then -- if eof
212+
local status = ctx.var.upstream_trailer_grpc_status
213+
local message = ctx.var.upstream_trailer_grpc_message
194214

195-
if ctx.grpc_web_encoding == CONTENT_ENCODING_BINARY then
196-
ngx_arg[1] = ngx_arg[1] .. trailer_buf
197-
else
198-
ngx_arg[1] = ngx_arg[1] .. encode_base64(trailer_buf)
215+
-- When the response body completes and still does not receive the grpc status
216+
local resp_ok = status ~= nil and status ~= ""
217+
local trailer_buf = build_trailer(
218+
resp_ok and status or 2,
219+
resp_ok and message or "upstream grpc status not received"
220+
)
221+
if ctx.grpc_web_encoding == CONTENT_ENCODING_BASE64 then
222+
trailer_buf = encode_base64(trailer_buf)
199223
end
200224

201-
-- clear trailer
202-
ctx.var.upstream_trailer_grpc_status = nil
203-
ctx.var.upstream_trailer_grpc_message = nil
225+
ngx_arg[1] = ngx_arg[1] .. trailer_buf
204226
end
205227
end
206228

t/plugin/grpc-web.t

+25-26
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,14 @@ passed
6969
7070
7171
=== TEST 2: Proxy unary request using APISIX with trailers gRPC-Web plugin
72+
Status should be printed at most once per request, otherwise this would be out of specification.
7273
--- exec
7374
node ./t/plugin/grpc-web/client.js BIN UNARY
7475
node ./t/plugin/grpc-web/client.js TEXT UNARY
7576
--- response_body
7677
Status: { code: 0, details: '', metadata: {} }
77-
Status: { code: 0, details: '', metadata: {} }
7878
{"name":"hello","path":"/hello"}
7979
Status: { code: 0, details: '', metadata: {} }
80-
Status: { code: 0, details: '', metadata: {} }
8180
{"name":"hello","path":"/hello"}
8281
8382
@@ -90,11 +89,9 @@ node ./t/plugin/grpc-web/client.js TEXT STREAM
9089
{"name":"hello","path":"/hello"}
9190
{"name":"world","path":"/world"}
9291
Status: { code: 0, details: '', metadata: {} }
93-
Status: { code: 0, details: '', metadata: {} }
9492
{"name":"hello","path":"/hello"}
9593
{"name":"world","path":"/world"}
9694
Status: { code: 0, details: '', metadata: {} }
97-
Status: { code: 0, details: '', metadata: {} }
9895
9996
10097
@@ -112,7 +109,7 @@ Access-Control-Allow-Origin: *
112109
=== TEST 5: test non-options request
113110
--- request
114111
GET /grpc/web/a6.RouteService/GetRoute
115-
--- error_code: 400
112+
--- error_code: 405
116113
--- response_headers
117114
Access-Control-Allow-Origin: *
118115
--- error_log
@@ -128,7 +125,7 @@ Content-Type: application/json
128125
--- error_code: 400
129126
--- response_headers
130127
Access-Control-Allow-Origin: *
131-
Content-Type: application/json
128+
Content-Type: text/html
132129
--- error_log
133130
request Content-Type: `application/json` invalid
134131
@@ -177,7 +174,7 @@ Content-Type: application/grpc-web
177174
--- error_code: 400
178175
--- response_headers
179176
Access-Control-Allow-Origin: *
180-
Content-Type: application/grpc-web
177+
Content-Type: text/html
181178
--- error_log
182179
routing configuration error, grpc-web plugin only supports `prefix matching` pattern routing
183180
@@ -226,33 +223,35 @@ passed
226223
227224
228225
=== TEST 10: don't override Access-Control-Allow-Origin header in response
229-
--- request
230-
POST /grpc/web/a6.RouteService/GetRoute
231-
{}
232-
--- more_headers
233-
Origin: http://test.com
234-
Content-Type: application/grpc-web
235-
--- response_headers
236-
Access-Control-Allow-Origin: http://test.com
237-
Content-Type: application/grpc-web
226+
--- exec
227+
curl -iv --location 'http://127.0.0.1:1984/grpc/web/a6.RouteService/GetRoute' \
228+
--header 'Origin: http://test.com' \
229+
--header 'Content-Type: application/grpc-web-text' \
230+
--data-raw 'AAAAAAcKBXdvcmxkCgo='
231+
--- response_body eval
232+
qr/HTTP\/1.1 200 OK/ and qr/Access-Control-Allow-Origin: http:\/\/test.com/
238233
239234
240235
241236
=== TEST 11: check for Access-Control-Expose-Headers header in response
242-
--- request
243-
POST /grpc/web/a6.RouteService/GetRoute
244-
{}
245-
--- more_headers
246-
Origin: http://test.com
247-
Content-Type: application/grpc-web
248-
--- response_headers
249-
Access-Control-Allow-Origin: http://test.com
250-
Access-Control-Expose-Headers: grpc-message,grpc-status
251-
Content-Type: application/grpc-web
237+
--- exec
238+
curl -iv --location 'http://127.0.0.1:1984/grpc/web/a6.RouteService/GetRoute' \
239+
--header 'Origin: http://test.com' \
240+
--header 'Content-Type: application/grpc-web-text' \
241+
--data-raw 'AAAAAAcKBXdvcmxkCgo='
242+
--- response_body eval
243+
qr/Access-Control-Expose-Headers: grpc-message,grpc-status/ and qr/Access-Control-Allow-Origin: http:\/\/test.com/
252244
253245
254246
255247
=== TEST 12: verify trailers in response
248+
According to the gRPC documentation, the grpc-web proxy should not retain trailers received from upstream when
249+
forwarding them, as the reference implementation envoy does, so the current test case is status quo rather
250+
than "correct", which is not expected to have an impact since browsers ignore trailers.
251+
Currently there is no API or hook point available in nginx/lua-nginx-module to remove specified trailers
252+
on demand (grpc_hide_header can do it but it affects the grpc proxy), and some nginx patches may be needed
253+
to allow for code-controlled removal of the trailer at runtime.
254+
When we implement that, this use case will be removed.
256255
--- exec
257256
curl -iv --location 'http://127.0.0.1:1984/grpc/web/a6.RouteService/GetRoute' \
258257
--header 'Content-Type: application/grpc-web+proto' \

0 commit comments

Comments
 (0)