##// END OF EJS Templates
hgweb: extract path traversal checking into standalone function...
Gregory Szorc -
r31790:62f9679d default
parent child Browse files
Show More
@@ -1,224 +1,232 b''
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
1 # hgweb/common.py - Utility functions needed by hgweb_mod and hgwebdir_mod
2 #
2 #
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
3 # Copyright 21 May 2005 - (c) 2005 Jake Edge <jake@edge2.net>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
4 # Copyright 2005, 2006 Matt Mackall <mpm@selenic.com>
5 #
5 #
6 # This software may be used and distributed according to the terms of the
6 # This software may be used and distributed according to the terms of the
7 # GNU General Public License version 2 or any later version.
7 # GNU General Public License version 2 or any later version.
8
8
9 from __future__ import absolute_import
9 from __future__ import absolute_import
10
10
11 import base64
11 import base64
12 import errno
12 import errno
13 import mimetypes
13 import mimetypes
14 import os
14 import os
15 import uuid
15 import uuid
16
16
17 from .. import (
17 from .. import (
18 encoding,
18 encoding,
19 pycompat,
19 pycompat,
20 util,
20 util,
21 )
21 )
22
22
23 httpserver = util.httpserver
23 httpserver = util.httpserver
24
24
25 HTTP_OK = 200
25 HTTP_OK = 200
26 HTTP_NOT_MODIFIED = 304
26 HTTP_NOT_MODIFIED = 304
27 HTTP_BAD_REQUEST = 400
27 HTTP_BAD_REQUEST = 400
28 HTTP_UNAUTHORIZED = 401
28 HTTP_UNAUTHORIZED = 401
29 HTTP_FORBIDDEN = 403
29 HTTP_FORBIDDEN = 403
30 HTTP_NOT_FOUND = 404
30 HTTP_NOT_FOUND = 404
31 HTTP_METHOD_NOT_ALLOWED = 405
31 HTTP_METHOD_NOT_ALLOWED = 405
32 HTTP_SERVER_ERROR = 500
32 HTTP_SERVER_ERROR = 500
33
33
34
34
35 def ismember(ui, username, userlist):
35 def ismember(ui, username, userlist):
36 """Check if username is a member of userlist.
36 """Check if username is a member of userlist.
37
37
38 If userlist has a single '*' member, all users are considered members.
38 If userlist has a single '*' member, all users are considered members.
39 Can be overridden by extensions to provide more complex authorization
39 Can be overridden by extensions to provide more complex authorization
40 schemes.
40 schemes.
41 """
41 """
42 return userlist == ['*'] or username in userlist
42 return userlist == ['*'] or username in userlist
43
43
44 def checkauthz(hgweb, req, op):
44 def checkauthz(hgweb, req, op):
45 '''Check permission for operation based on request data (including
45 '''Check permission for operation based on request data (including
46 authentication info). Return if op allowed, else raise an ErrorResponse
46 authentication info). Return if op allowed, else raise an ErrorResponse
47 exception.'''
47 exception.'''
48
48
49 user = req.env.get('REMOTE_USER')
49 user = req.env.get('REMOTE_USER')
50
50
51 deny_read = hgweb.configlist('web', 'deny_read')
51 deny_read = hgweb.configlist('web', 'deny_read')
52 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
52 if deny_read and (not user or ismember(hgweb.repo.ui, user, deny_read)):
53 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
53 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
54
54
55 allow_read = hgweb.configlist('web', 'allow_read')
55 allow_read = hgweb.configlist('web', 'allow_read')
56 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
56 if allow_read and (not ismember(hgweb.repo.ui, user, allow_read)):
57 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
57 raise ErrorResponse(HTTP_UNAUTHORIZED, 'read not authorized')
58
58
59 if op == 'pull' and not hgweb.allowpull:
59 if op == 'pull' and not hgweb.allowpull:
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
60 raise ErrorResponse(HTTP_UNAUTHORIZED, 'pull not authorized')
61 elif op == 'pull' or op is None: # op is None for interface requests
61 elif op == 'pull' or op is None: # op is None for interface requests
62 return
62 return
63
63
64 # enforce that you can only push using POST requests
64 # enforce that you can only push using POST requests
65 if req.env['REQUEST_METHOD'] != 'POST':
65 if req.env['REQUEST_METHOD'] != 'POST':
66 msg = 'push requires POST request'
66 msg = 'push requires POST request'
67 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
67 raise ErrorResponse(HTTP_METHOD_NOT_ALLOWED, msg)
68
68
69 # require ssl by default for pushing, auth info cannot be sniffed
69 # require ssl by default for pushing, auth info cannot be sniffed
70 # and replayed
70 # and replayed
71 scheme = req.env.get('wsgi.url_scheme')
71 scheme = req.env.get('wsgi.url_scheme')
72 if hgweb.configbool('web', 'push_ssl', True) and scheme != 'https':
72 if hgweb.configbool('web', 'push_ssl', True) and scheme != 'https':
73 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
73 raise ErrorResponse(HTTP_FORBIDDEN, 'ssl required')
74
74
75 deny = hgweb.configlist('web', 'deny_push')
75 deny = hgweb.configlist('web', 'deny_push')
76 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
76 if deny and (not user or ismember(hgweb.repo.ui, user, deny)):
77 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
77 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
78
78
79 allow = hgweb.configlist('web', 'allow_push')
79 allow = hgweb.configlist('web', 'allow_push')
80 if not (allow and ismember(hgweb.repo.ui, user, allow)):
80 if not (allow and ismember(hgweb.repo.ui, user, allow)):
81 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
81 raise ErrorResponse(HTTP_UNAUTHORIZED, 'push not authorized')
82
82
83 # Hooks for hgweb permission checks; extensions can add hooks here.
83 # Hooks for hgweb permission checks; extensions can add hooks here.
84 # Each hook is invoked like this: hook(hgweb, request, operation),
84 # Each hook is invoked like this: hook(hgweb, request, operation),
85 # where operation is either read, pull or push. Hooks should either
85 # where operation is either read, pull or push. Hooks should either
86 # raise an ErrorResponse exception, or just return.
86 # raise an ErrorResponse exception, or just return.
87 #
87 #
88 # It is possible to do both authentication and authorization through
88 # It is possible to do both authentication and authorization through
89 # this.
89 # this.
90 permhooks = [checkauthz]
90 permhooks = [checkauthz]
91
91
92
92
93 class ErrorResponse(Exception):
93 class ErrorResponse(Exception):
94 def __init__(self, code, message=None, headers=None):
94 def __init__(self, code, message=None, headers=None):
95 if message is None:
95 if message is None:
96 message = _statusmessage(code)
96 message = _statusmessage(code)
97 Exception.__init__(self, message)
97 Exception.__init__(self, message)
98 self.code = code
98 self.code = code
99 if headers is None:
99 if headers is None:
100 headers = []
100 headers = []
101 self.headers = headers
101 self.headers = headers
102
102
103 class continuereader(object):
103 class continuereader(object):
104 def __init__(self, f, write):
104 def __init__(self, f, write):
105 self.f = f
105 self.f = f
106 self._write = write
106 self._write = write
107 self.continued = False
107 self.continued = False
108
108
109 def read(self, amt=-1):
109 def read(self, amt=-1):
110 if not self.continued:
110 if not self.continued:
111 self.continued = True
111 self.continued = True
112 self._write('HTTP/1.1 100 Continue\r\n\r\n')
112 self._write('HTTP/1.1 100 Continue\r\n\r\n')
113 return self.f.read(amt)
113 return self.f.read(amt)
114
114
115 def __getattr__(self, attr):
115 def __getattr__(self, attr):
116 if attr in ('close', 'readline', 'readlines', '__iter__'):
116 if attr in ('close', 'readline', 'readlines', '__iter__'):
117 return getattr(self.f, attr)
117 return getattr(self.f, attr)
118 raise AttributeError
118 raise AttributeError
119
119
120 def _statusmessage(code):
120 def _statusmessage(code):
121 responses = httpserver.basehttprequesthandler.responses
121 responses = httpserver.basehttprequesthandler.responses
122 return responses.get(code, ('Error', 'Unknown error'))[0]
122 return responses.get(code, ('Error', 'Unknown error'))[0]
123
123
124 def statusmessage(code, message=None):
124 def statusmessage(code, message=None):
125 return '%d %s' % (code, message or _statusmessage(code))
125 return '%d %s' % (code, message or _statusmessage(code))
126
126
127 def get_stat(spath, fn):
127 def get_stat(spath, fn):
128 """stat fn if it exists, spath otherwise"""
128 """stat fn if it exists, spath otherwise"""
129 cl_path = os.path.join(spath, fn)
129 cl_path = os.path.join(spath, fn)
130 if os.path.exists(cl_path):
130 if os.path.exists(cl_path):
131 return os.stat(cl_path)
131 return os.stat(cl_path)
132 else:
132 else:
133 return os.stat(spath)
133 return os.stat(spath)
134
134
135 def get_mtime(spath):
135 def get_mtime(spath):
136 return get_stat(spath, "00changelog.i").st_mtime
136 return get_stat(spath, "00changelog.i").st_mtime
137
137
138 def ispathsafe(path):
139 """Determine if a path is safe to use for filesystem access."""
140 parts = path.split('/')
141 for part in parts:
142 if (part in ('', os.curdir, os.pardir) or
143 pycompat.ossep in part or
144 pycompat.osaltsep is not None and pycompat.osaltsep in part):
145 return False
146
147 return True
148
138 def staticfile(directory, fname, req):
149 def staticfile(directory, fname, req):
139 """return a file inside directory with guessed Content-Type header
150 """return a file inside directory with guessed Content-Type header
140
151
141 fname always uses '/' as directory separator and isn't allowed to
152 fname always uses '/' as directory separator and isn't allowed to
142 contain unusual path components.
153 contain unusual path components.
143 Content-Type is guessed using the mimetypes module.
154 Content-Type is guessed using the mimetypes module.
144 Return an empty string if fname is illegal or file not found.
155 Return an empty string if fname is illegal or file not found.
145
156
146 """
157 """
147 parts = fname.split('/')
158 if not ispathsafe(fname):
148 for part in parts:
159 return
149 if (part in ('', os.curdir, os.pardir) or
160
150 pycompat.ossep in part or
161 fpath = os.path.join(*fname.split('/'))
151 pycompat.osaltsep is not None and pycompat.osaltsep in part):
152 return
153 fpath = os.path.join(*parts)
154 if isinstance(directory, str):
162 if isinstance(directory, str):
155 directory = [directory]
163 directory = [directory]
156 for d in directory:
164 for d in directory:
157 path = os.path.join(d, fpath)
165 path = os.path.join(d, fpath)
158 if os.path.exists(path):
166 if os.path.exists(path):
159 break
167 break
160 try:
168 try:
161 os.stat(path)
169 os.stat(path)
162 ct = mimetypes.guess_type(path)[0] or "text/plain"
170 ct = mimetypes.guess_type(path)[0] or "text/plain"
163 with open(path, 'rb') as fh:
171 with open(path, 'rb') as fh:
164 data = fh.read()
172 data = fh.read()
165
173
166 req.respond(HTTP_OK, ct, body=data)
174 req.respond(HTTP_OK, ct, body=data)
167 except TypeError:
175 except TypeError:
168 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
176 raise ErrorResponse(HTTP_SERVER_ERROR, 'illegal filename')
169 except OSError as err:
177 except OSError as err:
170 if err.errno == errno.ENOENT:
178 if err.errno == errno.ENOENT:
171 raise ErrorResponse(HTTP_NOT_FOUND)
179 raise ErrorResponse(HTTP_NOT_FOUND)
172 else:
180 else:
173 raise ErrorResponse(HTTP_SERVER_ERROR, err.strerror)
181 raise ErrorResponse(HTTP_SERVER_ERROR, err.strerror)
174
182
175 def paritygen(stripecount, offset=0):
183 def paritygen(stripecount, offset=0):
176 """count parity of horizontal stripes for easier reading"""
184 """count parity of horizontal stripes for easier reading"""
177 if stripecount and offset:
185 if stripecount and offset:
178 # account for offset, e.g. due to building the list in reverse
186 # account for offset, e.g. due to building the list in reverse
179 count = (stripecount + offset) % stripecount
187 count = (stripecount + offset) % stripecount
180 parity = (stripecount + offset) / stripecount & 1
188 parity = (stripecount + offset) / stripecount & 1
181 else:
189 else:
182 count = 0
190 count = 0
183 parity = 0
191 parity = 0
184 while True:
192 while True:
185 yield parity
193 yield parity
186 count += 1
194 count += 1
187 if stripecount and count >= stripecount:
195 if stripecount and count >= stripecount:
188 parity = 1 - parity
196 parity = 1 - parity
189 count = 0
197 count = 0
190
198
191 def get_contact(config):
199 def get_contact(config):
192 """Return repo contact information or empty string.
200 """Return repo contact information or empty string.
193
201
194 web.contact is the primary source, but if that is not set, try
202 web.contact is the primary source, but if that is not set, try
195 ui.username or $EMAIL as a fallback to display something useful.
203 ui.username or $EMAIL as a fallback to display something useful.
196 """
204 """
197 return (config("web", "contact") or
205 return (config("web", "contact") or
198 config("ui", "username") or
206 config("ui", "username") or
199 encoding.environ.get("EMAIL") or "")
207 encoding.environ.get("EMAIL") or "")
200
208
201 def caching(web, req):
209 def caching(web, req):
202 tag = 'W/"%s"' % web.mtime
210 tag = 'W/"%s"' % web.mtime
203 if req.env.get('HTTP_IF_NONE_MATCH') == tag:
211 if req.env.get('HTTP_IF_NONE_MATCH') == tag:
204 raise ErrorResponse(HTTP_NOT_MODIFIED)
212 raise ErrorResponse(HTTP_NOT_MODIFIED)
205 req.headers.append(('ETag', tag))
213 req.headers.append(('ETag', tag))
206
214
207 def cspvalues(ui):
215 def cspvalues(ui):
208 """Obtain the Content-Security-Policy header and nonce value.
216 """Obtain the Content-Security-Policy header and nonce value.
209
217
210 Returns a 2-tuple of the CSP header value and the nonce value.
218 Returns a 2-tuple of the CSP header value and the nonce value.
211
219
212 First value is ``None`` if CSP isn't enabled. Second value is ``None``
220 First value is ``None`` if CSP isn't enabled. Second value is ``None``
213 if CSP isn't enabled or if the CSP header doesn't need a nonce.
221 if CSP isn't enabled or if the CSP header doesn't need a nonce.
214 """
222 """
215 # Don't allow untrusted CSP setting since it be disable protections
223 # Don't allow untrusted CSP setting since it be disable protections
216 # from a trusted/global source.
224 # from a trusted/global source.
217 csp = ui.config('web', 'csp', untrusted=False)
225 csp = ui.config('web', 'csp', untrusted=False)
218 nonce = None
226 nonce = None
219
227
220 if csp and '%nonce%' in csp:
228 if csp and '%nonce%' in csp:
221 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
229 nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=')
222 csp = csp.replace('%nonce%', nonce)
230 csp = csp.replace('%nonce%', nonce)
223
231
224 return csp, nonce
232 return csp, nonce
General Comments 0
You need to be logged in to leave comments. Login now