Access mode detection (transcode vs grpcweb vs grpc)#412
Access mode detection (transcode vs grpcweb vs grpc)#412polvalente merged 27 commits intoelixir-grpc:masterfrom
Conversation
These typically come from grpcweb clients doing OPTIONS checks for CORS
This replaces the `http_transcode` member of the `Server.Stream` struct as transcoding is correlated with being a web client (*not* a grpcweb client, however!) Knowing the type of client also allows for detection of when to send different types of headers, for e.g. CORS
e42b573 to
c2ae07d
Compare
It an option `:allow` option to define which origins are allowed. This may be a static string or a function which will be passed the current `req` and `stream` structs to choose what to allow.
c2ae07d to
e536825
Compare
sleipnir
left a comment
There was a problem hiding this comment.
I have only one objection to the approach and I explained it in the comment
NOTE: will merge this with commit c39c834 post-review/discussion. I am leaving it as a separate commit for now for ease of continued discussion on the current PR.
this more accurately and directly maps to the grpc terminology, and is probably clearer.
8c4ab5a to
61f4a78
Compare
|
I just pushed a couple more commits to this branch which:
I debated adding the http request headers to the To make the included Interceptor generically usable (or make it plausible for others to write their own), it appears "necessary" to include all the req headers. Without changing the Thoughts on that are welcome. FWIW, I have done functional testing with a SPA written with React, next.js, and the Other summary points that I will reiterate here for clarity and comment:
Anything else? Other thoughts? I may need to write some more unit tests somewhere around |
Great job! |
sleipnir
left a comment
There was a problem hiding this comment.
I have filed a request for changes but just so you can check a question I raised regarding the specification. Can you check it for us?
Only send access-control-allow-headers when the client requests it with access-control-request-headers and allow the same configurability as access-control-allow-origin provides
I have added one to the |
|
I approved this PR to approve the pipeline, but in this specific case I would like a view from @polvalente before moving on to the merge, maybe I missed something along the way, a second opinion is always good |
|
After some adjustments to the pipeline the tests ran ok. |
polvalente
left a comment
There was a problem hiding this comment.
Some security concerns, but overall looks good to me
README.md
Outdated
| end | ||
| ``` | ||
|
|
||
| By default, the CORS `Interceptor` responds to CORS requests with a permissive response that allows *all* points of origin to access the service. This may be undesirable, particularly in production environments. For this reason, both the allowed origin and headers are configurable: |
There was a problem hiding this comment.
I think we should have a default that requires the values and a separate allow_all option for dev mode that the user must opt-in to activate
There was a problem hiding this comment.
I think we should have a default that requires the values and a separate allow_all option
'*' is allow all, and is widely documented in relation to CORS, so special values really are not necessary for that.
The default would then be to not send any CORS headers, requiring some value to be set explicitly. Would that be OK for you?
There was a problem hiding this comment.
Yeah, make the option required then, without any defaults. We just can't default to allowing everything
README.md
Outdated
| The set of configuration directives supported include: | ||
|
|
||
| * `allow_origin` - A string containing the allowed origin(s), or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to `"*"`, which will allow all origins to access this endpoint. | ||
| * `allow_headers` - A string containing the allowed headers, or a remote function (e.g. `&MyApp.MyModule.function/2)`) which takes a `req` and a `stream` and returns a string. Defaults to the value of the `"access-control-request-headers"` request header from the client. |
There was a problem hiding this comment.
What's the format for allowed headers?
There was a problem hiding this comment.
In both cases I think we should receive {mod, name} instead of function because users might end up passing fn x, y -> end which will fail at runtime
There was a problem hiding this comment.
What's the format for allowed headers?
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers
{mod, name}
If that's what you want. I personally find this uglier and less idiomatic than a function capture (even the official Elixir lang docs recommend avoiding apply when possible). But the bigger issue is that this would also mean it wouldn't get checked and caught at compile time, but fail at runtime, which is not great.
users might end up passing fn x, y -> end which will fail at runtime
It actually fails at compile-time, as an anonymous function is not a remote function.
There was a problem hiding this comment.
Re headers:
We should add that link to the docs
Re function:
Given that we can enforce that it's a capture, we can keep it as is.
| allow_origin = | ||
| case Keyword.get(opts, :allow_origin) do | ||
| {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun | ||
| static when is_binary(static) -> static | ||
| _ -> "*" | ||
| end |
There was a problem hiding this comment.
As this is running in a macro context (I can't recall why, but let's roll with this), the current code does not allow for a variable to be referenced here, I think. It will end up defaulting to the catch-all silently and this is a security risk.
Perhaps this validation should be done at run time?
There was a problem hiding this comment.
It will end up defaulting to the catch-all silently and this is a security risk.
There are unit tests for this that say otherwise. See CORS allow origin header value is configuraable with a two-arity function in test/grpc/server/interceptors/cors_test.exs.
There was a problem hiding this comment.
Pass an atom/integer/tuple/list/variable referencing a binary instead of a capture or literal binary and this will fall through to "*".
There was a problem hiding this comment.
We should raise with ArgumentError
There was a problem hiding this comment.
Pass an atom/integer/tuple/list/variable referencing a binary instead of a capture or literal binary and this will fall through to "*".
This is actually covered in the documentation: a string or a remote function, otherwise is defaults to '*'. As was the default values used.
It's a moot point now, in any case, as I've removed the default for allow_origin altogether in 18ad11f which should address this and another of your concerns.
| request, | ||
| %{stream | access_mode: :grpcweb}, | ||
| fn _request, _stream -> {:ok, :ok} end, | ||
| CORSInterceptor.init() |
There was a problem hiding this comment.
This style of calling init explicitly won't allow for passing the capture without also allowing a function.
If we keep the capture as the chosen syntax, we need to guard against allowing the function AST.
IMO moving to the tuple will be simpler to support in both situations
There was a problem hiding this comment.
This style of calling init explicitly won't allow for passing the capture without also allowing a function.
This is a convenience for the unit test only. (... and is what the unit tests for the logging Interceptor does.) The unit tests for utilizing a remote function use an Endpoint, as that way init is called at compile-time in a macro context. I would do this for all the tests, but it's far uglier and less clear so I followed the precedent set in logger_test.exs where possible.
Of course, an Interceptor's init is always, and only, called within a macro as this library treats an Interceptor's init as a source of compile-time configuration data.
If we keep the capture as the chosen syntax, we need to guard against allowing the function AST.
If I understand what you are suggesting here, that already isn't possible as it is being run within a macro and so fails at compile time.
There was a problem hiding this comment.
Let's use the other method, though, as your code heavily relies on macro comparison and that can't be tested if you're calling things as you are here
lib/grpc/server/interceptors/cors.ex
Outdated
| allow_origin = | ||
| case Keyword.get(opts, :allow_origin) do | ||
| {:&, [], [{:/, [], [_signature, 2]}]} = fun -> fun | ||
| static when is_binary(static) -> static |
There was a problem hiding this comment.
You can add a explicit catch all here raising and ArgumentError for better UX.
This matching mode is also already enough to guard against runtime functions as I'd mentioned below.
There was a problem hiding this comment.
You can add a explicit catch all here raising and ArgumentError for better UX.
see b51feda
This matching mode is also already enough to guard against runtime functions as I'd mentioned below.
When called outside of a macro, yes. When called in a macro, it fails at compilation before it even gets there.
|
My available time for this PR has run out. Please feel free to do with it as you wish from here. |
polvalente
left a comment
There was a problem hiding this comment.
Thank you for the contribution! This was a tricky one to get through.
This introduces:
This is a draft for discussion in #237