Repository: rack/rack (Ruby)

Issue:
Colon should be required for HTTP Basic authentication
I have stumpled upon this, because the nginx in front of my Rack application is unable to extract `$remote_user` (see http://nginx.org/en/docs/http/ngx_http_core_module.html) for a specific client. Rack, on the other hand, authenticates the client successfully. The username is a UUIDv4, and the password is empty. It turns out that the problematic client builds the authentication header manually (rather than using a gem/library) and has forgotten to include the colon separating username and password.

Section 2 in RFC 2617 (https://tools.ietf.org/html/rfc2617#section-2) specifies that a colon must be included:

> To receive authorization, the client sends the userid and password, separated by a single colon (":") character, within a base64 encoded string in the credentials.
> 
> ```
> basic-credentials = base64-user-pass
> base64-user-pass  = <base64 encoding of user-pass, except not limited to 76 char/line>
> 
> user-pass   = userid ":" password
> userid      = *<TEXT excluding ":">
> password    = *TEXT
> ```
> 
> Userids might be case sensitive.
> 
> If the user agent wishes to send the userid "Aladdin" and password "open sesame", it would use the following header field:
> 
> ```
> Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
> ```

I suggest the following solution:

```
diff --git a/lib/rack/auth/basic.rb b/lib/rack/auth/basic.rb
index 9c58921..abdeb3b 100644
--- a/lib/rack/auth/basic.rb
+++ b/lib/rack/auth/basic.rb
@@ -41,7 +41,7 @@ def valid?(auth)
 
       class Request < Auth::AbstractRequest
         def basic?
-          "basic" == scheme
+          "basic" == scheme and params.unpack("m*").first.include?(':')
         end
 
         def credentials
```


Discussion:
This could only affect cases where the password was empty and the authenticator treated `nil` as a empty password (which sounds like a bug in the authenticator).

Still, I think it does make sense to be more strict here (so that it returns 400 instead of 401 in most cases), though we should use an approach that does not parse the `params` twice.  I'll work on a pull request for it.
What if the password or username itself has colon in it ? That will fail
@pghole Colon is not allowed in the user ID according to https://datatracker.ietf.org/doc/html/rfc2617#section-2:

> `userid      = *<TEXT excluding ":">`

It _is_ allowed in the password and that hopefully still works (I haven't tested).



Patch:
diff --git a/lib/rack/auth/basic.rb b/lib/rack/auth/basic.rb
index d334939ca..b61bfffe1 100644
--- a/lib/rack/auth/basic.rb
+++ b/lib/rack/auth/basic.rb
@@ -44,7 +44,7 @@ def valid?(auth)
 
       class Request < Auth::AbstractRequest
         def basic?
-          "basic" == scheme
+          "basic" == scheme && credentials.length == 2
         end
 
         def credentials


Test patch:
diff --git a/test/spec_auth_basic.rb b/test/spec_auth_basic.rb
index 3e479ace9..79d034b84 100644
--- a/test/spec_auth_basic.rb
+++ b/test/spec_auth_basic.rb
@@ -84,6 +84,15 @@ def assert_basic_auth_challenge(response)
     end
   end
 
+  it 'return 400 Bad Request for a authorization header with only username' do
+    auth = 'Basic ' + ['foo'].pack("m*")
+    request 'HTTP_AUTHORIZATION' => auth do |response|
+      response.must_be :client_error?
+      response.status.must_equal 400
+      response.wont_include 'WWW-Authenticate'
+    end
+  end
+
   it 'takes realm as optional constructor arg' do
     app = Rack::Auth::Basic.new(unprotected_app, realm) { true }
     realm.must_equal app.realm
diff --git a/test/spec_deflater.rb b/test/spec_deflater.rb
index 378e2cf30..f903c0128 100644
--- a/test/spec_deflater.rb
+++ b/test/spec_deflater.rb
@@ -76,7 +76,7 @@ def verify(expected_status, expected_body, accept_encoding, options = {}, &block
           Time.httpdate(last_mod).to_i.must_equal mtime
         else
           mtime.must_be(:<=, Time.now.to_i)
-          mtime.must_be(:>=, start.to_i)
+          mtime.must_be(:>=, start.to_i - 1)
         end
         tmp = gz.read
         gz.close