Skip to content

Always overwrite Transport-Encoding and Content-Length #479

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

Merged
merged 6 commits into from
Nov 23, 2021

Conversation

dnadoba
Copy link
Collaborator

@dnadoba dnadoba commented Nov 18, 2021

Motivation

We want to reuse request validation in the upcoming async/await implementation. During refactoring we noticed some inconsistency in the current implementation on how user defined Transport-Encoding and Content-Length headers are processed. We concluded that a user should not be allowed to set Transport-Encoding or Content-Length headers themselves. A user can only communicate its intend through the body property on a request. We infer the correct and most optimal Transport-Encoding or Content-Length header for them.

Changes

  • Transport-Encoding and Content-Length or no longer validated and always removed or replaced
  • rename validate to validateAndSetTransportFraming because it does not only validate but also mutate the HTTPHeaders to include the correct headers for the given request body.
  • introduce a new enum RequestBodyLength which can be either .dynamic if we do not now the size of a request in advance or .fixed(length: Int) if the user has specified the length of the body.
  • refactor implementation to no longer expect a HTTPClient.Request.Body? but rather a new type RequestBodyLength. This allows use to reuse the function for async/await because we will not use HTTPClient.Request.Body but rather a new type HTTPRequest.Body.
  • no request body (where HTTPClient.Request.body is set to nil) and a request body with a length of 0 (e.g where body is .byteBuffer(ByteBuffer()) are now processed the same way.

Alternatives

We could also remove RequestBodyLength and just use and optional Int.

let encodings = self[canonicalForm: "Transfer-Encoding"]

// "Transfer-Encoding" and "Content-Length" are not allowed to present at the same time (https://tools.ietf.org/html/rfc7230#section-3.3.1)
guard encodings.isEmpty || contentLength == nil else {
Copy link
Member

Choose a reason for hiding this comment

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

Super NIT: I think an if is easier to read here.

Comment on lines 127 to 129
static func validateTransferEncoding<Encodings>(
_ encodings: Encodings
) throws where Encodings: Sequence, Encodings.Element: StringProtocol {
Copy link
Member

Choose a reason for hiding this comment

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

Just out of curiosity, why is this generic?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We call this function during validateAndFixTransportFraming with [Substring] but it is more convenience to use [String] in tests.

Copy link
Member

Choose a reason for hiding this comment

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

I think this should be private and all tests should go directly through validateAndFixTransportFraming.

Copy link
Collaborator

Choose a reason for hiding this comment

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

+1 to @fabianfett's note.

}

private func validateFieldNames() throws {
func validateFieldNames() throws {
Copy link
Member

Choose a reason for hiding this comment

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

why is this internal now?

self.add(name: "Content-Length", value: String(length))
return .init(connectionClose: connectionClose, body: .fixedSize(length))
} else {
self.add(name: "Transfer-Encoding", value: encodings.joined(separator: ", "))
Copy link
Member

Choose a reason for hiding this comment

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

I think we should make this explicit with a comment.

Even though a fixed length body is going to be sent, the user explicitly set an encoding. We should respect the users wish and use the specified headers.

throw HTTPClientError.traceRequestWithBody
}
if encodings.isEmpty {
self.add(name: "Content-Length", value: String(length))
Copy link
Member

@fabianfett fabianfett Nov 18, 2021

Choose a reason for hiding this comment

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

There might be an interesting case here: What if the user handed us a fixed size body payload, but also set the Content-Length header to something different?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I once had validation in there but noticed that we actually have tests that check that we always overwrite the Content-Length header. I would also prefer to validate it but this might break user code. WDYT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

func testContentLengthHeaderIsChangedIfBodyHasDifferentLength() {
var headers = HTTPHeaders([("Content-Length", "0")])
var metadata: RequestFramingMetadata?
XCTAssertNoThrow(metadata = try headers.validateAndFixTransportFraming(method: .PUT, bodyLength: .fixed(length: 200)))
XCTAssertEqual(headers.first(name: "Content-Length"), "200")
XCTAssertEqual(metadata?.body, .fixedSize(200))
}

Copy link
Member

Choose a reason for hiding this comment

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

We need @Lukasa's wisdom here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We should absolutely always override it. The user is not entitled to set that header: it doesn't belong to them.

// if a user forgot to specify a Content-Length and Transfer-Encoding, we will set it for them
self.add(name: "Transfer-Encoding", value: "chunked")
} else {
self.add(name: "Transfer-Encoding", value: encodings.joined(separator: ", "))
Copy link
Member

Choose a reason for hiding this comment

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

We also run in this case if the user has set a Content-Length header explicitly. I think in the dynamic case we should trust a user set content length header (though we should validate it is a positive int).

A case might be streaming a file somewhere.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not really sure about this. A user can always specify the length of a stream upfront through our API and doesn't need to set the Content-Length header manually through the length parameter.

/// Create and stream body using `StreamWriter`.
///
/// - parameters:
/// - length: Body size. Request validation will be failed with `HTTPClientErrors.contentLengthMissing` if nil,
/// unless `Transfer-Encoding: chunked` header is set.
/// - stream: Body chunk provider.
public static func stream(length: Int? = nil, _ stream: @escaping (StreamWriter) -> EventLoopFuture<Void>) -> Body {
return Body(length: length, stream: stream)
}

This would give them a way to do the same thing in to different ways and they may conflict.
I would almost say that we should always ignore/remove "Content-Length" header.
The same is almost true for Transport-Encoding but I'm not fully understanding all use cases for this one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

If I read this code snipped correctly, Transport-Encoding can only be chunked because SwiftNIO always replace the Transport-Encoding with chunked or doesn't do chunked encoding if it is not the only encoding.
https://github.com/apple/swift-nio/blob/addf69cfe60376c325397c8926589415576b1dd1/Sources/NIOHTTP1/HTTPEncoder.swift#L104-L129

Copy link
Member

Choose a reason for hiding this comment

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

we should always ignore/remove "Content-Length" header

Nope, definitely not. I know at least the S3 apis, that require a Content-Length even for large uploads, if you deal with pre-signed links and friends.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, we don't support other transfer encodings in NIO and so we should probably stop doing so here. However, I don't know if making that change in this PR is worthwhile.

As to supporting Content-Length: yes, we should emit it, but we should do so when users tell us the size of their stream. We shouldn't tolerate them setting that header.

Copy link
Member

Choose a reason for hiding this comment

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

As to supporting Content-Length: yes, we should emit it, but we should do so when users tell us the size of their stream. We shouldn't tolerate them setting that header.

Okay, I'm happy with that!

In that case we need to fix the code comment though here:

/// Create and stream body using `StreamWriter`.
///
/// - parameters:
/// - length: Body size. Request validation will be failed with `HTTPClientErrors.contentLengthMissing` if nil,
/// unless `Transfer-Encoding: chunked` header is set.
/// - stream: Body chunk provider.
public static func stream(length: Int? = nil, _ stream: @escaping (StreamWriter) -> EventLoopFuture<Void>) -> Body {
return Body(length: length, stream: stream)
}

If no Content-Length header is set, we will just reach for chunked.

@dnadoba dnadoba force-pushed the dn-refactor-request-validation branch from 62ad818 to 539e4fd Compare November 18, 2021 19:57
@Lukasa Lukasa added the 🔨 semver/patch No public API change. label Nov 22, 2021
//
//===----------------------------------------------------------------------===//

enum RequestBodyLength: Equatable {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: As a matter of style I think it's rarely useful to have : Equatable. Whenever you feel like writing that, you may as well also write : Hashable.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Is the binary size increase negligible for the additional synthesised conformance? AFAIK Swift doesn't remove unused synthesised conformances, even for internal types.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, this is not the most fruitful way of shrinking binary sizes.

method: HTTPMethod,
bodyLength: RequestBodyLength
) throws -> RequestFramingMetadata {
let contentLength = self.first(name: "Content-Length")
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why are we getting the value if we don't need it?

throw HTTPClientError.traceRequestWithBody
}
if encodings.isEmpty {
self.add(name: "Content-Length", value: String(length))
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should absolutely always override it. The user is not entitled to set that header: it doesn't belong to them.

let contentLength = self.first(name: "Content-Length")
let encodings = self[canonicalForm: "Transfer-Encoding"]

// "Transfer-Encoding" and "Content-Length" are not allowed to present at the same time (https://tools.ietf.org/html/rfc7230#section-3.3.1)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit:

Suggested change
// "Transfer-Encoding" and "Content-Length" are not allowed to present at the same time (https://tools.ietf.org/html/rfc7230#section-3.3.1)
// "Transfer-Encoding" and "Content-Length" are not allowed to be present at the same time (https://tools.ietf.org/html/rfc7230#section-3.3.1)

Comment on lines 127 to 129
static func validateTransferEncoding<Encodings>(
_ encodings: Encodings
) throws where Encodings: Sequence, Encodings.Element: StringProtocol {
Copy link
Collaborator

Choose a reason for hiding this comment

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

+1 to @fabianfett's note.

// if a user forgot to specify a Content-Length and Transfer-Encoding, we will set it for them
self.add(name: "Transfer-Encoding", value: "chunked")
} else {
self.add(name: "Transfer-Encoding", value: encodings.joined(separator: ", "))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, we don't support other transfer encodings in NIO and so we should probably stop doing so here. However, I don't know if making that change in this PR is worthwhile.

As to supporting Content-Length: yes, we should emit it, but we should do so when users tell us the size of their stream. We shouldn't tolerate them setting that header.

@dnadoba dnadoba force-pushed the dn-refactor-request-validation branch from 539e4fd to afad35f Compare November 22, 2021 13:41
@dnadoba dnadoba force-pushed the dn-refactor-request-validation branch from afad35f to 311d927 Compare November 22, 2021 13:41
@dnadoba dnadoba changed the title Refactor request validation Always overwrite Transport-Encoding and Content-Length Nov 22, 2021
Copy link
Member

@fabianfett fabianfett left a comment

Choose a reason for hiding this comment

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

Wow, this looks so much better already. Tiny nits.

@@ -937,6 +939,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
/// Request contains invalid identity encoding.
public static let identityCodingIncorrectlyPresent = HTTPClientError(code: .identityCodingIncorrectlyPresent)
/// Request contains multiple chunks definitions.
@available(*, deprecated)
Copy link
Member

Choose a reason for hiding this comment

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

We should add a message here like: "AsyncHTTPClient now silently corrects invalid headers."

@@ -959,6 +962,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
/// Body part was written after request was fully sent.
public static let writeAfterRequestSent = HTTPClientError(code: .writeAfterRequestSent)
/// Incompatible headers specified, for example `Transfer-Encoding` and `Content-Length`.
@available(*, deprecated)
Copy link
Member

Choose a reason for hiding this comment

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

See above


func testTraceMethodIsNotAllowedToHaveAFixedLengthBody() {
var headers = HTTPHeaders()
XCTAssertThrowsError(try headers.validateAndSetTransportFraming(method: .TRACE, bodyLength: .fixed(length: 10)))
Copy link
Member

Choose a reason for hiding this comment

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

Since the errors are equatable would you mind checking, we get what we expect?

Copy link
Member

@fabianfett fabianfett left a comment

Choose a reason for hiding this comment

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

Huge improvement!

@fabianfett fabianfett requested a review from Lukasa November 22, 2021 17:17
@dnadoba dnadoba merged commit 0ed00b8 into swift-server:main Nov 23, 2021
@dnadoba dnadoba deleted the dn-refactor-request-validation branch November 23, 2021 09:23
@fabianfett fabianfett added 🆕 semver/minor Adds new public API. and removed 🔨 semver/patch No public API change. labels Nov 23, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🆕 semver/minor Adds new public API.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants