Merge lp:~leonardr/launchpad/true-anonymous-access into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 9552
Proposed branch: lp:~leonardr/launchpad/true-anonymous-access
Merge into: lp:launchpad/db-devel
Diff against target: 141 lines (+88/-9)
2 files modified
lib/canonical/launchpad/pagetests/webservice/xx-service.txt (+70/-2)
lib/canonical/launchpad/webapp/servers.py (+18/-7)
To merge this branch: bzr merge lp:~leonardr/launchpad/true-anonymous-access
Reviewer Review Type Date Requested Status
Guilherme Salgado (community) Approve
Gary Poster (community) Approve
Martin Pool (community) Approve
Review via email: mp+30088@code.launchpad.net

Description of the change

This branch makes it possible to get read-only access to the Launchpad web service (the actual one, at api.launchpad.net, not the one for Ajax apps) using an OAuth-ignorant client like wget or a web browser. Internally, the User-Agent string is treated as the "consumer key", and anonymous read-only access is allowed on the same terms as we currently allow OAuth requests signed with a token that is the empty string.

Although I'm confident there are none, I would like the reviewer to take a close look for possible security problems such as privilege escalation attacks within getPrincipal().

My real problem with this branch is that there is no longer a real failure mode for web service implementations. If you screw up your OAuth implementation, or you try to authenticate with Basic Auth and your Launchpad username/password, you will no longer get an exception. You'll get anonymous read-only access. But, I don't think it's worth making a big deal of this, trying to get failure modes back by checking for incomplete OAuth implementations, etc. (At most, I might check for an Authorization header that's not a valid OAuth Authorization header.)

To post a comment you must log in.
Revision history for this message
Martin Pool (mbp) wrote :

That looks good to me, though I'm not very expert on Launchpad's security internals.

> If you screw up your OAuth implementation, or you try to authenticate with Basic Auth and your Launchpad username/password, you will no longer get an exception. You'll get anonymous read-only access. But, I don't think it's worth making a big deal of this, trying to get failure modes back by checking for incomplete OAuth implementations, etc. (At most, I might check for an Authorization header that's not a valid OAuth Authorization header.)

I think it would be reasonable to handle that as a separate bug if and when anyone actually files it. If the OAuth header is corrupt as opposed to just absent I think we should still give an error.

Did you interactively test that against your dev instance?

Thanks very much, this is a nice step forward for API usability.

review: Approve
Revision history for this message
Leonard Richardson (leonardr) wrote :

I did test this branch interactively against my dev instance, using a web browser and wget.

I'd like salgado to take a quick look from a security standpoint and then I'll land it.

Revision history for this message
Gary Poster (gary) wrote :

Looks good to me.

I don't see how this changes the security profile, so I'm +1 on landing it. I'm certainly happy to get another review, though.

review: Approve
Revision history for this message
Guilherme Salgado (salgado) wrote :

<salgado> leonardr, it looks fine to me, but an unrelated thing is that it is not clear to me that consumers.getByKey() may create a new token as the comment states

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/pagetests/webservice/xx-service.txt'
2--- lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2010-04-27 15:56:20 +0000
3+++ lib/canonical/launchpad/pagetests/webservice/xx-service.txt 2010-07-19 14:51:33 +0000
4@@ -156,6 +156,73 @@
5 (<Person at...>, 'displayname', 'launchpad.Edit')
6 ...
7
8+A completely unsigned web service request is treated as an anonymous
9+request, with the OAuth consumer name being equal to the User-Agent.
10+
11+ >>> agent = "unsigned-user-agent"
12+ >>> login(ANONYMOUS)
13+ >>> print consumer_set.getByKey(agent)
14+ None
15+ >>> logout()
16+
17+ >>> from zope.app.testing.functional import HTTPCaller
18+ >>> def request_with_user_agent(agent, url="/devel"):
19+ ... if agent is None:
20+ ... agent_string = ''
21+ ... else:
22+ ... agent_string = '\nUser-Agent: %s' % agent
23+ ... http = HTTPCaller()
24+ ... request = ("GET %s HTTP/1.1\n"
25+ ... "Host: api.launchpad.dev"
26+ ... "%s\n\n") % (url, agent_string)
27+ ... return http(request)
28+
29+ >>> response = request_with_user_agent(agent)
30+ >>> print response.getOutput()
31+ HTTP/1.1 200 Ok
32+ ...
33+ {...}
34+
35+Here, too, the OAuth consumer name is automatically registered if it
36+doesn't exist.
37+
38+ >>> login(ANONYMOUS)
39+ >>> print consumer_set.getByKey(agent).key
40+ unsigned-user-agent
41+ >>> logout()
42+
43+Here's another request now that the User-Agent has been registered as
44+a consumer name.
45+
46+ >>> response = request_with_user_agent(agent)
47+ >>> print response.getOutput()
48+ HTTP/1.1 200 Ok
49+ ...
50+ {...}
51+
52+An unsigned request, like a request signed with the empty string,
53+isn't logged in as any particular user:
54+
55+ >>> response = request_with_user_agent(agent, "/devel/people/+me")
56+ >>> print response.getOutput()
57+ HTTP/1.1 401 Unauthorized
58+ ...
59+ Unauthorized: You need to be logged in to view this URL.
60+
61+An empty or missing user agent results in an error.
62+
63+ >>> response = request_with_user_agent(' ')
64+ >>> print response.getOutput()
65+ HTTP/1.1 401 Unauthorized
66+ ...
67+ Unauthorized: Anonymous requests must provide a User-Agent.
68+
69+ >>> response = request_with_user_agent(None)
70+ >>> print response.getOutput()
71+ HTTP/1.1 401 Unauthorized
72+ ...
73+ Unauthorized: Anonymous requests must provide a User-Agent.
74+
75 API Requests to other hosts
76 ===========================
77
78@@ -200,7 +267,8 @@
79 ...
80
81 But the regular authentication still doesn't work on the normal API
82-virtual host:
83+virtual host: an attempt to do HTTP Basic Auth will be treated as an
84+anonymous request.
85
86 >>> noauth_webservice.domain = 'api.launchpad.dev'
87 >>> print noauth_webservice.get(
88@@ -208,7 +276,7 @@
89 ... headers={'Authorization': sample_auth})
90 HTTP/1.1 401 Unauthorized
91 ...
92- Request is missing an OAuth consumer key.
93+ Unauthorized: Anonymous requests must provide a User-Agent.
94
95
96 The 'Vary' Header
97
98=== modified file 'lib/canonical/launchpad/webapp/servers.py'
99--- lib/canonical/launchpad/webapp/servers.py 2010-04-27 15:56:20 +0000
100+++ lib/canonical/launchpad/webapp/servers.py 2010-07-19 14:51:33 +0000
101@@ -1199,22 +1199,33 @@
102 consumer = consumers.getByKey(consumer_key)
103 token_key = form.get('oauth_token')
104 anonymous_request = (token_key == '')
105+
106+ if consumer_key is None:
107+ # Either the client's OAuth implementation is broken, or
108+ # the user is trying to make an unauthenticated request
109+ # using wget or another OAuth-ignorant application.
110+ # Try to retrieve a consumer based on the User-Agent
111+ # header.
112+ anonymous_request = True
113+ consumer_key = request.getHeader('User-Agent', '')
114+ if consumer_key == '':
115+ raise Unauthorized(
116+ 'Anonymous requests must provide a User-Agent.')
117+ consumer = consumers.getByKey(consumer_key)
118+
119 if consumer is None:
120- if consumer_key is None:
121- # Most likely the user is trying to make a totally
122- # unauthenticated request.
123- raise Unauthorized(
124- 'Request is missing an OAuth consumer key.')
125 if anonymous_request:
126 # This is the first time anyone has tried to make an
127- # anonymous request using this consumer
128- # name. Dynamically create the consumer.
129+ # anonymous request using this consumer name (or user
130+ # agent). Dynamically create the consumer.
131 #
132 # In the normal website this wouldn't be possible
133 # because GET requests have their transactions rolled
134 # back. But webservice requests always have their
135 # transactions committed so that we can keep track of
136 # the OAuth nonces and prevent replay attacks.
137+ if consumer_key == '' or consumer_key is None:
138+ raise Unauthorized("No consumer key specified.")
139 consumer = consumers.new(consumer_key, '')
140 else:
141 # An unknown consumer can never make a non-anonymous

Subscribers

People subscribed via source and target branches

to status/vote changes: