Is this a security vulnerability?
No.
Issue
The current classifiers are misleading because they seem to represent relations between different sets of errors which should be completely distinct and, to some extent, abuse the error type.
Affected Versions
All.
Context
These two changes are closely related which is why I opened this as one issue. If we feel like this is not a good idea I can split it into two separate issues.
Suggested Changes
There are two (relatively) independent changes that I would like to discuss.
Make All Classifier Groups Distinct
Just because the groups happen to share some errors / classifiers they shouldn't depend on each other. This is a possible source of confusion and has caused mistakes in the past.
diff --git a/proxy/fails/classifier_group.go b/proxy/fails/classifier_group.go
index d0a1da5..b790c01 100644
--- a/proxy/fails/classifier_group.go
+++ b/proxy/fails/classifier_group.go
@@ -1,41 +1,57 @@
package fails
type ClassifierGroup []Classifier
// RetriableClassifiers include backend errors that are safe to retry
//
// Backend errors are only safe to retry if we can be certain that they have
// occurred before any http request data has been sent from gorouter to the
// backend application.
//
// Otherwise, there’s risk of a mutating non-idempotent request (e.g. send
// payment) being silently retried without the client knowing.
var RetriableClassifiers = ClassifierGroup{
Dial,
AttemptedTLSWithNonTLSBackend,
HostnameMismatch,
RemoteFailedCertCheck,
RemoteHandshakeFailure,
RemoteHandshakeTimeout,
UntrustedCert,
ExpiredOrNotYetValidCertFailure,
IdempotentRequestEOF,
IncompleteRequest,
}
var FailableClassifiers = ClassifierGroup{
- RetriableClassifiers,
+ Dial,
+ AttemptedTLSWithNonTLSBackend,
+ HostnameMismatch,
+ RemoteFailedCertCheck,
+ RemoteHandshakeFailure,
+ RemoteHandshakeTimeout,
+ UntrustedCert,
+ ExpiredOrNotYetValidCertFailure,
ConnectionResetOnRead,
}
-var PrunableClassifiers = RetriableClassifiers
+var PrunableClassifiers = ClassifierGroup{
+ Dial,
+ AttemptedTLSWithNonTLSBackend,
+ HostnameMismatch,
+ RemoteFailedCertCheck,
+ RemoteHandshakeFailure,
+ RemoteHandshakeTimeout,
+ UntrustedCert,
+ ExpiredOrNotYetValidCertFailure,
+}
// Classify returns true on errors that are retryable
func (cg ClassifierGroup) Classify(err error) bool {
for _, classifier := range cg {
if classifier.Classify(err) {
return true
}
}
return false
}
This change includes a minor tweak to the groups as well: IdempotentRequestEOF and IncompleteRequest are no longer part of the FailableClassifiers and PruneableClassifiers because those two errors are only "annotated" versions of an underlying error. Their sole purpose is to be able to match them using the RetriableClassifiers because we checked some pre-condition that allows us to retry the wrapped error even though we usually wouldn't be able to retry in that case.
Get Rid of "annotated" Errors
We initially introduced IdempotentRequestEOF and IncompleteRequest because we needed a way to tell the classifiers that those errors are retry-able without unconditionally retrying all of the errors that might get wrapped inside them. This included an additional check which is done in isRetriable.
The main issue is that we wrap errors without providing details that are particularly relevant in the sense that they enrich the error message. They purely exist because we need to pass a value of type error to the classifier groups. Instead I propose to split the logic for performing retries:
diff --git a/proxy/round_tripper/proxy_round_tripper.go b/proxy/round_tripper/proxy_round_tripper.go
index 5d2c9d0..09fd093 100644
--- a/proxy/round_tripper/proxy_round_tripper.go
+++ b/proxy/round_tripper/proxy_round_tripper.go
@@ -440,24 +439,23 @@ func isIdempotent(request *http.Request) bool {
return false
}
-func (rt *roundTripper) isRetriable(request *http.Request, err error, trace *requestTracer) (bool, error) {
+func (rt *roundTripper) isRetriable(request *http.Request, err error, trace *requestTracer) bool {
// if the context has been cancelled we do not perform further retries
if request.Context().Err() != nil {
- return false, fmt.Errorf("%w (%w)", request.Context().Err(), err)
+ return false
}
// io.EOF errors are considered safe to retry for certain requests
// Replace the error here to track this state when classifying later.
if err == io.EOF && isIdempotent(request) {
- err = fails.IdempotentRequestEOFError
+ return true
}
// We can retry for sure if we never obtained a connection
// since there is no way any data was transmitted. If headers could not
// be written in full, the request should also be safe to retry.
if !trace.GotConn() || !trace.WroteHeaders() {
- err = fmt.Errorf("%w (%w)", fails.IncompleteRequestError, err)
+ return true
}
- retriable := rt.retriableClassifier.Classify(err)
- return retriable, err
+ return rt.retriableClassifier.Classify(err)
}
and completely remove IdempotentRequestEOF and IncompleteRequest. This way we get the benefits of our additional retries either from the checks that allow us to retry in special cases or from the classifiers while not tampering with the errors that are passed around and even displayed to the user (and I'm pretty sure that end-users would be confused if they see incomplete request (EOF) in response to their request).
Next Steps
Is this a security vulnerability?
No.
Issue
The current classifiers are misleading because they seem to represent relations between different sets of errors which should be completely distinct and, to some extent, abuse the
errortype.Affected Versions
All.
Context
These two changes are closely related which is why I opened this as one issue. If we feel like this is not a good idea I can split it into two separate issues.
Suggested Changes
There are two (relatively) independent changes that I would like to discuss.
Make All Classifier Groups Distinct
Just because the groups happen to share some errors / classifiers they shouldn't depend on each other. This is a possible source of confusion and has caused mistakes in the past.
This change includes a minor tweak to the groups as well:
IdempotentRequestEOFandIncompleteRequestare no longer part of theFailableClassifiersandPruneableClassifiersbecause those two errors are only "annotated" versions of an underlying error. Their sole purpose is to be able to match them using theRetriableClassifiersbecause we checked some pre-condition that allows us to retry the wrapped error even though we usually wouldn't be able to retry in that case.Get Rid of "annotated" Errors
We initially introduced
IdempotentRequestEOFandIncompleteRequestbecause we needed a way to tell the classifiers that those errors are retry-able without unconditionally retrying all of the errors that might get wrapped inside them. This included an additional check which is done inisRetriable.The main issue is that we wrap errors without providing details that are particularly relevant in the sense that they enrich the error message. They purely exist because we need to pass a value of type
errorto the classifier groups. Instead I propose to split the logic for performing retries:and completely remove
IdempotentRequestEOFandIncompleteRequest. This way we get the benefits of our additional retries either from the checks that allow us to retry in special cases or from the classifiers while not tampering with the errors that are passed around and even displayed to the user (and I'm pretty sure that end-users would be confused if they seeincomplete request (EOF)in response to their request).Next Steps