Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Forwarded proto #2519

Merged
merged 2 commits into from
Jan 23, 2024
Merged

Forwarded proto #2519

merged 2 commits into from
Jan 23, 2024

Conversation

atezet
Copy link
Member

@atezet atezet commented Apr 12, 2023

PR for #2425. Some notes:

@atezet atezet force-pushed the forwarded-proto branch 2 times, most recently from 753c56b to 9443a35 Compare April 21, 2023 12:22
@atezet atezet marked this pull request as ready for review April 21, 2023 12:23
@SergioBenitez
Copy link
Member

Thanks for the work on this. It's top on my list for early next week. I appreciate the patience.

core/lib/src/config/config.rs Outdated Show resolved Hide resolved
core/lib/src/cookies.rs Outdated Show resolved Hide resolved
Comment on lines 172 to 168
/// * **Protocol**
///
/// Extracts the protocol of the incoming request as a [`Protocol`] via
/// [`Request::proxy_proto()`] (HTTP or HTTPS). If the used protocol is not
/// known, the request is forwarded.
///
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems problematic. I would expect such a guard to succeed 99.9% of the time, given the name of the type. But this will only work when the header is set. Is there a benefit to exposing this? If we really want to do this, we should change the name to something less general, like ForwardedProtocol, or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While working on this I thought I would need this, but I realised that in the end we didn't but it might be useful to someone else. As it's trivial to implement, I will just remove it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to keep it, as the tests I added are using it, but I renamed it to ForwardedProtocol.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This discussion still stands, unless keeping it is now deemed acceptable. Also see #2519 (comment)

@SergioBenitez
Copy link
Member

SergioBenitez commented May 2, 2023

My initial inclination (beside "awesome job!") is that this is doing far too much. I would have expected the implementation to:

  • Add a new config parameter that configures a header to check.
  • Add a method somewhere that allows checking whether we're in a secure context that effectively returns tls_enabled() || have_secure_header() .
  • Call that new method from the relevant default() methods in Cookie.

I'd like to change this implementation such that we get there. In particular, I really dislike set_proxy_secure: it adds states to a Cookie where there was none before. Instead the cookie just needs to know whether it should set the Secure flag or not, and we should provide a way to do that.

I'm not convinced by the name Protocol. I think we should use something more specific to the context. Perhaps ForwardedProto, or ProxyProtocol, or something like that.

@atezet
Copy link
Member Author

atezet commented May 2, 2023

I renamed everything to ForwardedProtocol and forwarded_secure (except the part in CookieJar as I assumed we will remove it). As said, I could use some guidance on implementing a better way to determine whether we are in a secure context when setting the defaults for a new cookie.

Copy link
Member

@SergioBenitez SergioBenitez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some consideration, I feel like we should really call it ProxyProtocol or ProxyProto, as it is quite literally the protocol that the proxy is using to communicate. Similarly, the config parameter should be called proxy_proto_header or similar, and the rest of the code should be adjusted accordingly.

With respect to how the CookieJar determines whether it's in a secure context, I really feel like we need a better approach. Right now, you have to call set_proxy_secure everywhere that we create a Request. But this is really error prone. It's clear that CookieJar needs to have everything it needs when it's created. This way, we can't forget to set something later.

The ideal would be to have CookieJar::new() ask for an &Request. Unfortunately this would mean that Request would need to hold a borrow to itself (Request contains RequestState which contains CookieJar), which is no fun. And it also wouldn't really work since we don't know the headers the request will have until later, in the case of LocalRequest. I think the next best thing to do is to:

  1. Remove the set_() method(s).
  2. Change the code so we have a new request.context_is_likely_secure() method.
  3. Have a single constructor for CookieJar that takes both the &Config (which we need for the secret key) and a new secure_context: bool which will be set to config.tls_enabled() || request.context_is_likely_secure(). When we don't have the final request, we can't really do anything. But we should change Request::from_hyp() so that it actually calls CookieJar::new() instead of adding to the existing cookie jar. At least then we're not mutating something. The CookieJar created in RequestState will just have the context config.tls_enabled(), which is correct but suboptimal.

I'll give this a second look later today to see if there's something more robust we can do.

core/http/src/header/forwarded_protocol.rs Outdated Show resolved Hide resolved
core/http/src/header/forwarded_protocol.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
@atezet atezet force-pushed the forwarded-proto branch 2 times, most recently from 85b7f48 to ab072f4 Compare May 3, 2023 21:43
@atezet
Copy link
Member Author

atezet commented May 3, 2023

Implement your suggestions, except for the from_hyp rewrite, as I didn't have time for that yet. Feel free to work on it if you find the time, but otherwise I'll take a look soon hopefully

@SergioBenitez
Copy link
Member

These latest changes look great! Pending consistently using the CookieJar constructor and fixing the testing (you can run ./scripts/test.sh locally to get the same output), we should be good to go. I'll let you push this across the finish line.

@atezet
Copy link
Member Author

atezet commented May 5, 2023

Unexpectedly had some time today. Is this more or less what you had in mind?

I also optimised the ProxyProtocol implementation a tiny bit

Copy link
Member

@SergioBenitez SergioBenitez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. These suggestions are largely for me. I'll make the changes and get this committed. Thank you!

core/lib/src/config/config.rs Outdated Show resolved Hide resolved
core/lib/src/request/from_request.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
site/guide/9-configuration.md Outdated Show resolved Hide resolved
@atezet
Copy link
Member Author

atezet commented May 5, 2023

Thanks! I see now that I forgot to change some comments to the new wording. Do you want me to do any of those changes? I did one more suggestion for a mistake of mine in the docs

@SergioBenitez
Copy link
Member

SergioBenitez commented May 6, 2023

Thanks for noticing that! Caught it in a commit I just pushed that addresses all of the issues I've brought up.

The original ideas and implementation were buggy as well as they didn't react correctly if the header was set after the request was received, e.g., by a request fairing. This commit fixes this by going through the existing header cache busting infra.

This work brought to light a different issue, however: any cookies added to the jar before such a header is set won't be marked "secure". This was the case in previous iterations as well, of course, it's just that this implementation made the issue obvious.

It's unclear to me whether this is a big concern or not. Certainly, what's we're doing now is better than what we did before. However, we might also be providing a false sense of security that has edge cases, which might be worse. The potential paths forward I see are:

  1. Scrap the entire idea.
  2. Make cookie.state.secure user modifiable, maybe along with 1.
  3. Set Secure right before we generate the final response. Headers are no longer modifiable at this point, so the edge case is removed. This introduces some unfortunate complexity to the code as doing this in a performant manner requires a custom Header type or else we have to clone cookies.
  4. Document this edge case really well.

I'm inclined to go with 4. It's really unlikely this edge case will be hit, and even more unlikely that it will cause any sort of security issue. But I can be convinced to go with 3.

@SergioBenitez
Copy link
Member

SergioBenitez commented May 6, 2023

Oh, I should say, the following things still need to be done:

  • Document the defaulting behavior in Cookie::{add, add_private}().
  • Fix the set_{private}defaults methods' docs.
  • Probably document the configuration variable specifically in the configuration guide.

@atezet
Copy link
Member Author

atezet commented May 6, 2023

Thanks for the cleanup, it’d gotten a bit of mess after the back and forth renaming. I really like what you did with the CookieState, that is much nicer.

So, to get to the best decision, I’d like to reiterate the idea behind the PR. In order to be able to add one more secure default to cookies set by Rocket running behind a proxy, the x-forwarded-for header is inspected. This header is intended to be set by the proxy as a way for Rocket to identify the protocol used for a connection. If the proxy terminates the TLS connection, there’d be no other way for Rocket to know about it. This all happens before the request reaches Rocket. Therefore I think it is not necessary to take into account any request fairings modifying the headers, as the proxy proto header is not intended to set in there. However, I do think that cookies added by fairings should make use of the new default, if possible, as it would be desirable and expected that these have the same defaults as cookies set in request handlers.

Hope this helps in reaching a conclusion.

@atezet
Copy link
Member Author

atezet commented May 23, 2023

@SergioBenitez So I bought about it some more and, unless I understand request fairings wrongly, I do think inspecting the header before it reaches any request fairing (i.e. in from_hyp) is the right behaviour as the x-forwarded-for header is supposed to be set by the proxy terminating the TLS connection, and any cookies set in fairings should default to secure (if the header is set to https) as well.

Let me know if you have any more concerns so we can finish this PR

@SergioBenitez
Copy link
Member

@arjentz I'm willing to move forward with merging this PR. Can you address the final remaining issues outlined in #2519 (comment)?

@atezet
Copy link
Member Author

atezet commented May 31, 2023

Thanks! I will add that, hopefully somewhere this week, otherwise next week. Do you agree that moving the logic back to from_hyp is the way to go? I can create a little POC to demonstrate the reasoning behind why it belongs there.

@SergioBenitez
Copy link
Member

Do you agree that moving the logic back to from_hyp is the way to go?

Not as of now. Can you explain why you think this is the way to go? As far as I see it, doing this has the following immediate drawbacks:

  • It means a fairing can never set or remove the header and have it impact the Secure setting for Cookie. While you're right that the header likely comes from the original request, we cannot simply rule out the possibility of a sane reason for a fairing to set or remove it. For example, one might do this because multiple proxies are being used, and some combination of headers set by those proxies should be used as the real header value. Or a fairing might want to remove the header value entirely because it doesn't want to trust the header value in some dynamic cases. Both of these approaches become impossible.
  • There must now be independent logic between "real" requests (where from_hyp is called) and local requests (i.e, via Client) so that the secure context state in CookieJar is set appropriately. This makes the implementation harder to reason about and harder to get right.

The only drawback to the current approach is an additional string comparison when a header is set.

any cookies set in fairings should default to secure (if the header is set to https) as well.

This the case with the either approach.

@atezet
Copy link
Member Author

atezet commented Aug 18, 2023

@SergioBenitez did you see my comment here? I used the following code to test:

#[macro_use] extern crate rocket;

use rocket::http::{Cookie, CookieJar};

#[get("/")]
fn hello(jar: &CookieJar<'_>) -> &'static str {
    jar.add_private(Cookie::new("a", "a"));
    "Hello, world!"
}

#[launch]
fn rocket() -> _ {
    let config = rocket::Config{proxy_proto_header: Some("x-forwarded-proto".into()), ..rocket::Config::debug_default()};
    rocket::build().mount("/", routes![hello]).configure(config)
}

But it doesn't seem to work now (rev = "e8d326c9d9584f8295989ae6d771fec632bdbcef"):

$ curl -sI -H "x-forwarded-proto: https" 127.0.0.1:8000 | grep "set-cookie"
set-cookie: a=6segmjzwEo1YzIHEo306Rl6X8l1exTLc%2FUkEFCs%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Fri, 25 Aug 2023 19:10:48 GMT

Even though it did before (rev = "52bd6fd2c1a12e41f6999b0e92e3118be8a948c6"):

$ curl -sI -H "x-forwarded-proto: https" 127.0.0.1:8000 | grep "set-cookie"
set-cookie: a=kxg8sjz8m0SpSGoXqssn1p9yKCtDNO4Ctd0vV9s%3D; HttpOnly; SameSite=Strict; Secure; Path=/; Expires=Fri, 25 Aug 2023 19:10:59 GMT

core/lib/src/config/config.rs Outdated Show resolved Hide resolved
core/lib/src/request/from_request.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
@SergioBenitez
Copy link
Member

@SergioBenitez did you see my comment here? I used the following code to test:

#[macro_use] extern crate rocket;

use rocket::http::{Cookie, CookieJar};

#[get("/")]
fn hello(jar: &CookieJar<'_>) -> &'static str {
    jar.add_private(Cookie::new("a", "a"));
    "Hello, world!"
}

#[launch]
fn rocket() -> _ {
    let config = rocket::Config{proxy_proto_header: Some("x-forwarded-proto".into()), ..rocket::Config::debug_default()};
    rocket::build().mount("/", routes![hello]).configure(config)
}

But it doesn't seem to work now (rev = "e8d326c9d9584f8295989ae6d771fec632bdbcef"):

$ curl -sI -H "x-forwarded-proto: https" 127.0.0.1:8000 | grep "set-cookie"
set-cookie: a=6segmjzwEo1YzIHEo306Rl6X8l1exTLc%2FUkEFCs%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Fri, 25 Aug 2023 19:10:48 GMT

Even though it did before (rev = "52bd6fd2c1a12e41f6999b0e92e3118be8a948c6"):

$ curl -sI -H "x-forwarded-proto: https" 127.0.0.1:8000 | grep "set-cookie"
set-cookie: a=kxg8sjz8m0SpSGoXqssn1p9yKCtDNO4Ctd0vV9s%3D; HttpOnly; SameSite=Strict; Secure; Path=/; Expires=Fri, 25 Aug 2023 19:10:59 GMT

Let me rebase on master to retest.

@SergioBenitez
Copy link
Member

Ah, right, yes, you're absolutely right. In the bust_header_cache, we check for the header, but we call that method before we actually insert the header. So, indeed, this won't work as you suggest. Let me fix this.

@SergioBenitez
Copy link
Member

This work brought to light a different issue, however: any cookies added to the jar before such a header is set won't be marked "secure". This was the case in previous iterations as well, of course, it's just that this implementation made the issue obvious.

Note that this can only really occur in local testing, where the jar is retrieved manually before setting a header. Otherwise, we only return a Request when we've finished parsing header in an incoming request.

@SergioBenitez
Copy link
Member

Okay, I've fixed the issue while also simplifying the impl. Please take a look and let me know what you think. Also check out the unresolved review items as well as my commentary for what's still left to be done.

@atezet
Copy link
Member Author

atezet commented Dec 19, 2023

Thanks for taking the time. I really like how you simplified it, especially the CookieState in the CookieJar. I checked all open discussions and resolved what was fixed, superseded or outdated. I've also added a few more updates to comments, including the first two points in #2519 (comment). The third one is still to be done, which I want to make sure to do right. I'll try to look at it this week, if you find the time, could you have a look at the other changes and two discussions I left open in the meantime?

Copy link
Member

@SergioBenitez SergioBenitez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor changes here, nothing interesting. I think we're at the finish line!

For the documentation on when context_is_likely_secure() affects defaults, we need to say that the Secure flag is set as long as it's not explicitly set to false. It's already mentioned that this is a "default", but I think being explicit here is worthwhile.

core/lib/src/config/config.rs Outdated Show resolved Hide resolved
core/lib/src/cookies.rs Outdated Show resolved Hide resolved
core/lib/src/cookies.rs Outdated Show resolved Hide resolved
core/lib/src/cookies.rs Outdated Show resolved Hide resolved
core/lib/src/cookies.rs Outdated Show resolved Hide resolved
core/lib/src/request/request.rs Outdated Show resolved Hide resolved
| `max_blocking`* | `usize` | Limit on threads to start for blocking tasks. | `512` |
| `ident` | `string`, `false` | If and how to identify via the `Server` header. | `"Rocket"` |
| `ip_header` | `string`, `false` | IP header to inspect to get [client's real IP]. | `"X-Real-IP"` |
| `proxy_proto_header` | `string`, `false` | Header identifying client to proxy protocol. | `None` |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should link ProxyProto the way LogLevel does, for example?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I linked proxy_proto the way ip_header does. I figured ProxyProto itself is not particularly useful here, as it is not used as the setting itself.

@atezet
Copy link
Member Author

atezet commented Jan 13, 2024

Rebased the branch and added the configuration entry. Happy to iterate on that a bit more if you think it needs that.

Copy link
Member

@SergioBenitez SergioBenitez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're set functionality wise. The docs need a bit of work. I'll fix them up, push a commit, and get this merged in a bit.

core/http/src/header/proxy_proto.rs Outdated Show resolved Hide resolved
core/lib/src/config/config.rs Outdated Show resolved Hide resolved
#[get("/")]
fn inspect_proto(proto: Option<ProxyProto>) -> String {
proto
.map(|proto| match proto {
ProxyProto::Http => "http".to_owned(),
ProxyProto::Https => "https".to_owned(),
ProxyProto::Unknown(s) => s.to_string(),
})
.unwrap_or("<none>".to_owned())
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[get("/")]
fn inspect_proto(proto: Option<ProxyProto>) -> String {
proto
.map(|proto| match proto {
ProxyProto::Http => "http".to_owned(),
ProxyProto::Https => "https".to_owned(),
ProxyProto::Unknown(s) => s.to_string(),
})
.unwrap_or("<none>".to_owned())
}
#[get("/")]
fn inspect_proto(proto: ProxyProto) -> String {
proto.to_string()
}

And then adjust the tests so they look for a 500 when there is no such header.

assert_eq!(response.into_string().unwrap(), "https");

let response = client.get("/").dispatch();
assert_eq!(response.into_string().unwrap(), "<none>");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g.:

Suggested change
assert_eq!(response.into_string().unwrap(), "<none>");
assert_eq!(response.status(), Status::InternalServerError);

Comment on lines 155 to 156
ip_header = "X-Real-IP" # set to `false` or `None` to disable
proxy_proto_header = `false` # set to `false` or `None` to disable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is saying None correct here? This implies you can write:

ip_header = None
proxy_proto_header = None

And it'll disable the header. But that isn't the case.

Suggested change
ip_header = "X-Real-IP" # set to `false` or `None` to disable
proxy_proto_header = `false` # set to `false` or `None` to disable
ip_header = "X-Real-IP" # set to `false` to disable
proxy_proto_header = `false` # set to `false` to disable

Comment on lines 368 to 374
If Rocket is running behind a reverse proxy that terminates TLS, it is useful to
know whether the original connection was made securely. Therefore, Rocket offers
the option to configure a `proxy_proto_header` that is used to determine if the
request is handled in a secure context. The outcome is available via
[`Request::context_is_likely_secure()`] and used to set cookies' secure flag by
default. To enable this behaviour, configure the header as set by your reverse
proxy. For example:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, we should be more specific: why is it useful? I'd suggest inverting this paragraph to something like:

Suggested change
If Rocket is running behind a reverse proxy that terminates TLS, it is useful to
know whether the original connection was made securely. Therefore, Rocket offers
the option to configure a `proxy_proto_header` that is used to determine if the
request is handled in a secure context. The outcome is available via
[`Request::context_is_likely_secure()`] and used to set cookies' secure flag by
default. To enable this behaviour, configure the header as set by your reverse
proxy. For example:
The `proxy_proto_header` configuration parameter allows Rocket applications to
determine when and if a client's initial connection was likely made in a
secure context by examining the header with the configured name. The
header's value is parsed into a [`ProxyProto`] value and retrievable via
[`Request::proxy_proto()`].
That value is in-turn inspected to determine if the initial protocol was secure
(i.e, over TLS) and the result made available via
[`Request::context_is_likely_secure()`]. The value returned by this method
influences cookie defaults. In particular, if the method returns `true` (the
request context is likely secure) secure, the cookie `Secure` cookie flag is set
by default.

@SergioBenitez SergioBenitez merged commit e9b568d into rwf2:master Jan 23, 2024
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants