2

I found out that editing a full_description of a DockerHub repository can be done via a JavaScript API, and figured this would be a fun excuse to learn the requests package for python. The JavaScript API definitely works, e.g. using this simple docker image.

The JS API basically does

  1. Send a POST request to https://hub.docker.com/v2/users/login with the username and password. The server responds with a token.
  2. Send a PATCH request to the specific https://hub.docker.com/v2/repositories/{user or org}/{repo}, making sure the header has Authorization: JWT {token}, and in this case with content body of {"full_description":"...value..."}.

What is troubling is that the PATCH request on the python side gets a 200 response back from the server (if you intentionally set a bad auth token, you get denied as expected). But it's response actually contains the current information (not the patched info).

The only "discoveries" I've made:

  • If you add the debug logging stuff, there's a 301. But this is the same URL for the javascript side, so it doesn't matter?

    send: b'{"full_description": "TEST"}'
    reply: 'HTTP/1.1 301 MOVED PERMANENTLY\r\n'
    
  • The token received by doing a POST in requests is the same as if I GET to auth.docker.io as decribed in Getting a Bearer Token section here. Notably, I didn't specify a password (just did curl -X GET ...). This is not true. They are different, I don't know how I thought they were the same.

This second one makes me feel like I'm missing a step. Like I need to decode the token or something? I don't know what else to make of this, especially the 200 response from the PATCH despite no changes.

The code:

import json
from textwrap import indent
import requests


if __name__ == "__main__":
    username = "<< SET THIS VALUE >>"
    password = "<< SET THIS VALUE >>"
    repo = "<< SET THIS VALUE >>"

    base_url = "https://hub.docker.com/v2"
    login_url = f"{base_url}/users/login"
    repo_url = f"{base_url}/repositories/{username}/{repo}"

    # NOTE: if I use a `with requests.Session()`, then I'll get
    # CSRF Failed: CSRF token missing or incorrect
    # Because I think that csrftoken is only valid for login page (?)
    # Get login token and create authorization header
    print("==> Logging into DockerHub")
    tok_req = requests.post(login_url, json={"username": username, "password": password})
    token = tok_req.json()["token"]
    headers = {"Authorization": f"JWT {token}"}

    print(f"==> Sending PATCH request to {repo_url}")
    payload = {"full_description": "TEST"}
    patch_req = requests.patch(repo_url, headers=headers, json=payload)
    print(f"    Response (status code: {patch_req.status_code}):")
    print(indent(json.dumps(patch_req.json(), indent=2), "    "))
6
  • 1
    Can you check the type of token? I'm not sure if I remember correctly but in python sometimes stuff gets converted to bytestring and all of a sudden it doesn't work anymore. Commented Mar 17, 2019 at 12:17
  • Hmm, that's a good thought, I hadn't considered that. The type(token) is str, but I'll dig in on that a little more. It could be that there's some knobs I can turn on how requests or maybe urllib3 actually decodes the transmitted response. Commented Mar 18, 2019 at 8:06
  • 1
    Can you also confirm that the token you get is an actual JWT? The Getting a bearer token link you posted seems to tell that you need to encode the reply to get a functional JWT but I'm not sure without knowing what the reply on the request is. Commented Mar 18, 2019 at 13:03
  • Edited, I have no idea why I thought they were the same...they aren't. Can you also confirm that the token you get is an actual JWT? I have no idea, but I'm afraid to post the response that comes from giving my username / password ;) I'm playing with the jwt module, I think I either need to encode or decode something like you say. I wish I could help you help me, but I don't know what I don't know x0 I appreciate you offering suggestions though, I'll keep grinding :) Commented Mar 18, 2019 at 13:50
  • Please could you clarify the problem. I don't understand what you think is unexpected about the way the PATCH request/response behaves. You mention "200 response from the PATCH despite no changes," but to me, that is expected. If I sent an identical PATCH request multiple times I would expect to get back 200 OK each time. Commented Sep 19, 2019 at 10:23

2 Answers 2

1

Additional information related to your CSRF problem when using requests.Session():

It seems that Docker Hub is not recognizing csrftoken named header/cookie (default name of the coming cookie), when making requests in this case. Instead, when using header X-CSRFToken on the following requests, CSRF is identified as valid. Maybe reason is cookie-to-header token pattern.

Once updating session header with cookie of login response

s.headers.update({"X-CSRFToken": s.cookies.get("csrftoken")})

There is no need to set JWT token manually anymore for further requests - token works as cookie already.

Sorry, no enough privileges to just comment, but I think this is relevant enough.

Sign up to request clarification or add additional context in comments.

2 Comments

awesome, thanks for sharing (and welcome to SO)! :)
Thank you for letting me make this comment!
0

As it turns out the JWT {token} auth was valid the entire time. Apparently, you need a / at the end of the URL. Without it, nothing happens. LOL!

# ----------------------------------------------------V
repo_url = f"{base_url}/repositories/{username}/{repo}/"

As expected, the PATCH then responds with the updated description, not the old description. WOOOOT!

Important note: this is working for me as of January 15th 2020, but in my quest I came across this dockerhub issue that seems to indicate that if you have 2FA enabled on your account, you can no longer edit the description using a PATCH request. I don't have 2FA on my account, so I can (apparently). It's unclear what the future of that will be.

Related note: the JWT token has remained the same the entire time, so for any web novices like myself, don't share those ;)

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.