##// END OF EJS Templates
fix(git-lfs): fixed security problem with allowing off-chain attacks to replace OID data without validating hash for already present oids....
super-admin -
r1300:a680a605 default
parent child Browse files
Show More
@@ -14,7 +14,7 b''
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software Foundation,
15 # along with this program; if not, write to the Free Software Foundation,
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
17 import hashlib
18 import re
18 import re
19 import logging
19 import logging
20
20
@@ -160,8 +160,14 b' def lfs_objects_oid_upload(request):'
160 engine = store.get_engine(mode='wb')
160 engine = store.get_engine(mode='wb')
161 log.debug('LFS: starting chunked write of LFS oid: %s to storage', oid)
161 log.debug('LFS: starting chunked write of LFS oid: %s to storage', oid)
162
162
163 # validate if OID is not by any chance already in the store
164 if store.has_oid():
165 log.debug('LFS: oid %s exists in store', oid)
166 return {'upload': 'ok', 'state': 'in-store'}
167
163 body = request.environ['wsgi.input']
168 body = request.environ['wsgi.input']
164
169
170 digest = hashlib.sha256()
165 with engine as f:
171 with engine as f:
166 blksize = 64 * 1024 # 64kb
172 blksize = 64 * 1024 # 64kb
167 while True:
173 while True:
@@ -175,10 +181,17 b' def lfs_objects_oid_upload(request):'
175
181
176 if not chunk:
182 if not chunk:
177 break
183 break
184 f.write(chunk)
185 digest.update(chunk)
178
186
179 f.write(chunk)
187 hex_digest = digest.hexdigest()
188 digest_check = hex_digest == oid
189 if not digest_check:
190 engine.cleanup() # trigger cleanup so we don't save mismatch OID into the store
191 return write_response_error(
192 HTTPBadRequest, f'oid {oid} does not match expected sha {hex_digest}')
180
193
181 return {'upload': 'ok'}
194 return {'upload': 'ok', 'state': 'written'}
182
195
183
196
184 def lfs_objects_oid_download(request):
197 def lfs_objects_oid_download(request):
@@ -223,12 +236,10 b' def lfs_objects_verify(request):'
223
236
224 store_size = store.size_oid()
237 store_size = store.size_oid()
225 if store_size != size:
238 if store_size != size:
226 msg = 'requested file size mismatch store size:{} requested:{}'.format(
239 msg = f'requested file size mismatch store size:{store_size} requested:{size}'
227 store_size, size)
240 return write_response_error(HTTPUnprocessableEntity, msg)
228 return write_response_error(
229 HTTPUnprocessableEntity, msg)
230
241
231 return {'message': {'size': 'ok', 'in_store': 'ok'}}
242 return {'message': {'size': store_size, 'oid': oid}}
232
243
233
244
234 def lfs_objects_lock(request):
245 def lfs_objects_lock(request):
@@ -133,12 +133,16 b' class LFSOidStore:'
133 """
133 """
134
134
135 class StoreEngine:
135 class StoreEngine:
136 _cleanup = None
136 def __init__(self, mode, store_path, oid_path, tmp_oid_path):
137 def __init__(self, mode, store_path, oid_path, tmp_oid_path):
137 self.mode = mode
138 self.mode = mode
138 self.store_path = store_path
139 self.store_path = store_path
139 self.oid_path = oid_path
140 self.oid_path = oid_path
140 self.tmp_oid_path = tmp_oid_path
141 self.tmp_oid_path = tmp_oid_path
141
142
143 def cleanup(self):
144 self._cleanup = True
145
142 def __enter__(self):
146 def __enter__(self):
143 if not os.path.isdir(self.store_path):
147 if not os.path.isdir(self.store_path):
144 os.makedirs(self.store_path)
148 os.makedirs(self.store_path)
@@ -149,9 +153,13 b' class LFSOidStore:'
149 return fd
153 return fd
150
154
151 def __exit__(self, exc_type, exc_value, traceback):
155 def __exit__(self, exc_type, exc_value, traceback):
152 # close tmp file, and rename to final destination
153 self.fd.close()
156 self.fd.close()
154 shutil.move(self.tmp_oid_path, self.oid_path)
157
158 if self._cleanup is None:
159 # close tmp file, and rename to final destination
160 shutil.move(self.tmp_oid_path, self.oid_path)
161 else:
162 os.remove(self.tmp_oid_path)
155
163
156 return StoreEngine(
164 return StoreEngine(
157 mode, self.store_path, self.oid_path, self.tmp_oid_path)
165 mode, self.store_path, self.oid_path, self.tmp_oid_path)
@@ -100,22 +100,27 b' class TestLFSApplication:'
100
100
101 def test_app_batch_api_download_missing_object(
101 def test_app_batch_api_download_missing_object(
102 self, git_lfs_app, http_auth):
102 self, git_lfs_app, http_auth):
103 params = {'operation': 'download',
103 params = {
104 'objects': [{'oid': '123', 'size': '1024'}]}
104 'operation': 'download',
105 'objects': [{'oid': '123', 'size': '1024'}]
106 }
105 response = git_lfs_app.post_json(
107 response = git_lfs_app.post_json(
106 '/repo/info/lfs/objects/batch', params=params,
108 '/repo/info/lfs/objects/batch', params=params,
107 extra_environ=http_auth)
109 extra_environ=http_auth)
108
110
109 expected_objects = [
111 expected_objects = [
110 {'authenticated': True,
112 {
111 'errors': {'error': {
113 'oid': '123',
112 'code': 404,
114 'size': '1024',
113 'message': 'object: 123 does not exist in store'}},
115 'authenticated': True,
114 'oid': '123',
116 'errors': {'error': {'code': 404, 'message': 'object: 123 does not exist in store'}},
115 'size': '1024'}
117 }
116 ]
118 ]
119
117 assert json.loads(response.text) == {
120 assert json.loads(response.text) == {
118 'objects': expected_objects, 'transfer': 'basic'}
121 'objects': expected_objects,
122 'transfer': 'basic'
123 }
119
124
120 def test_app_batch_api_download(self, git_lfs_app, http_auth):
125 def test_app_batch_api_download(self, git_lfs_app, http_auth):
121 oid = '456'
126 oid = '456'
@@ -142,7 +147,9 b' class TestLFSApplication:'
142 'size': '1024'}
147 'size': '1024'}
143 ]
148 ]
144 assert json.loads(response.text) == {
149 assert json.loads(response.text) == {
145 'objects': expected_objects, 'transfer': 'basic'}
150 'objects': expected_objects,
151 'transfer': 'basic'
152 }
146
153
147 def test_app_batch_api_upload(self, git_lfs_app, http_auth):
154 def test_app_batch_api_upload(self, git_lfs_app, http_auth):
148 params = {'operation': 'upload',
155 params = {'operation': 'upload',
@@ -151,21 +158,31 b' class TestLFSApplication:'
151 '/repo/info/lfs/objects/batch', params=params,
158 '/repo/info/lfs/objects/batch', params=params,
152 extra_environ=http_auth)
159 extra_environ=http_auth)
153 expected_objects = [
160 expected_objects = [
154 {'authenticated': True,
161 {
155 'actions': {
162 'authenticated': True,
156 'upload': {
163 'actions': {
157 'header': {'Authorization': 'Basic XXXXX',
164 'upload': {
158 'Transfer-Encoding': 'chunked'},
165 'header': {
159 'href': 'http://localhost/repo/info/lfs/objects/123'},
166 'Authorization': 'Basic XXXXX',
160 'verify': {
167 'Transfer-Encoding': 'chunked'
161 'header': {'Authorization': 'Basic XXXXX'},
168 },
162 'href': 'http://localhost/repo/info/lfs/verify'}
169 'href': 'http://localhost/repo/info/lfs/objects/123'
163 },
170 },
164 'oid': '123',
171 'verify': {
165 'size': '1024'}
172 'header': {
173 'Authorization': 'Basic XXXXX'
174 },
175 'href': 'http://localhost/repo/info/lfs/verify'
176 }
177 },
178 'oid': '123',
179 'size': '1024'
180 }
166 ]
181 ]
167 assert json.loads(response.text) == {
182 assert json.loads(response.text) == {
168 'objects': expected_objects, 'transfer': 'basic'}
183 'objects': expected_objects,
184 'transfer': 'basic'
185 }
169
186
170 def test_app_batch_api_upload_for_https(self, git_lfs_https_app, http_auth):
187 def test_app_batch_api_upload_for_https(self, git_lfs_https_app, http_auth):
171 params = {'operation': 'upload',
188 params = {'operation': 'upload',
@@ -178,7 +195,7 b' class TestLFSApplication:'
178 'actions': {
195 'actions': {
179 'upload': {
196 'upload': {
180 'header': {'Authorization': 'Basic XXXXX',
197 'header': {'Authorization': 'Basic XXXXX',
181 'Transfer-Encoding': 'chunked'},
198 'Transfer-Encoding': 'chunked'},
182 'href': 'https://localhost/repo/info/lfs/objects/123'},
199 'href': 'https://localhost/repo/info/lfs/objects/123'},
183 'verify': {
200 'verify': {
184 'header': {'Authorization': 'Basic XXXXX'},
201 'header': {'Authorization': 'Basic XXXXX'},
@@ -206,7 +223,8 b' class TestLFSApplication:'
206 status=404)
223 status=404)
207
224
208 assert json.loads(response.text) == {
225 assert json.loads(response.text) == {
209 'message': 'oid `missing` does not exists in store'}
226 'message': 'oid `missing` does not exists in store'
227 }
210
228
211 def test_app_verify_api_size_mismatch(self, git_lfs_app):
229 def test_app_verify_api_size_mismatch(self, git_lfs_app):
212 oid = 'existing'
230 oid = 'existing'
@@ -221,8 +239,8 b' class TestLFSApplication:'
221 '/repo/info/lfs/verify', params=params, status=422)
239 '/repo/info/lfs/verify', params=params, status=422)
222
240
223 assert json.loads(response.text) == {
241 assert json.loads(response.text) == {
224 'message': 'requested file size mismatch '
242 'message': 'requested file size mismatch store size:11 requested:1024'
225 'store size:11 requested:1024'}
243 }
226
244
227 def test_app_verify_api(self, git_lfs_app):
245 def test_app_verify_api(self, git_lfs_app):
228 oid = 'existing'
246 oid = 'existing'
@@ -237,13 +255,13 b' class TestLFSApplication:'
237 '/repo/info/lfs/verify', params=params)
255 '/repo/info/lfs/verify', params=params)
238
256
239 assert json.loads(response.text) == {
257 assert json.loads(response.text) == {
240 'message': {'size': 'ok', 'in_store': 'ok'}}
258 'message': {'size': 11, 'oid': oid}
259 }
241
260
242 def test_app_download_api_oid_not_existing(self, git_lfs_app):
261 def test_app_download_api_oid_not_existing(self, git_lfs_app):
243 oid = 'missing'
262 oid = 'missing'
244
263
245 response = git_lfs_app.get(
264 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}', status=404)
246 '/repo/info/lfs/objects/{oid}'.format(oid=oid), status=404)
247
265
248 assert json.loads(response.text) == {
266 assert json.loads(response.text) == {
249 'message': 'requested file with oid `missing` not found in store'}
267 'message': 'requested file with oid `missing` not found in store'}
@@ -256,19 +274,37 b' class TestLFSApplication:'
256 with open(oid_path, 'wb') as f:
274 with open(oid_path, 'wb') as f:
257 f.write(safe_bytes('OID_CONTENT'))
275 f.write(safe_bytes('OID_CONTENT'))
258
276
259 response = git_lfs_app.get(
277 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}')
260 '/repo/info/lfs/objects/{oid}'.format(oid=oid))
261 assert response
278 assert response
262
279
263 def test_app_upload(self, git_lfs_app):
280 def test_app_upload(self, git_lfs_app):
264 oid = 'uploaded'
281 oid = '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'
265
282
266 response = git_lfs_app.put(
283 response = git_lfs_app.put(
267 '/repo/info/lfs/objects/{oid}'.format(oid=oid), params='CONTENT')
284 f'/repo/info/lfs/objects/{oid}', params='CONTENT')
268
285
269 assert json.loads(response.text) == {'upload': 'ok'}
286 assert json.loads(response.text) == {'upload': 'ok', 'state': 'written'}
270
287
271 # verify that we actually wrote that OID
288 # verify that we actually wrote that OID
272 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
289 oid_path = LFSOidStore(oid=oid, repo=None, store_location=git_lfs_app._store).oid_path
273 assert os.path.isfile(oid_path)
290 assert os.path.isfile(oid_path)
274 assert 'CONTENT' == open(oid_path).read()
291 assert 'CONTENT' == open(oid_path).read()
292
293 response = git_lfs_app.put(
294 f'/repo/info/lfs/objects/{oid}', params='CONTENT')
295
296 assert json.loads(response.text) == {'upload': 'ok', 'state': 'in-store'}
297
298
299 def test_app_upload_wrong_sha(self, git_lfs_app):
300 oid = 'i-am-a-wrong-sha'
301
302 response = git_lfs_app.put(f'/repo/info/lfs/objects/{oid}', params='CONTENT', status=400)
303
304 assert json.loads(response.text) == {
305 'message': 'oid i-am-a-wrong-sha does not match expected sha '
306 '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'}
307
308 # check this OID wasn't written to store
309 response = git_lfs_app.get(f'/repo/info/lfs/objects/{oid}', status=404)
310 assert json.loads(response.text) == {'message': 'requested file with oid `i-am-a-wrong-sha` not found in store'}
@@ -24,7 +24,7 b' from vcsserver.git_lfs.lib import OidHan'
24 @pytest.fixture()
24 @pytest.fixture()
25 def lfs_store(tmpdir):
25 def lfs_store(tmpdir):
26 repo = 'test'
26 repo = 'test'
27 oid = '123456789'
27 oid = '65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12'
28 store = LFSOidStore(oid=oid, repo=repo, store_location=str(tmpdir))
28 store = LFSOidStore(oid=oid, repo=repo, store_location=str(tmpdir))
29 return store
29 return store
30
30
@@ -63,7 +63,8 b' class TestOidHandler:'
63 assert response is None
63 assert response is None
64 assert has_errors['error'] == {
64 assert has_errors['error'] == {
65 'code': 404,
65 'code': 404,
66 'message': 'object: 123456789 does not exist in store'}
66 'message': 'object: 65f23e22a9bfedda96929b3cfcb8b6d2fdd34a2e877ddb81f45d79ab05710e12 does not exist in store'
67 }
67
68
68 def test_download_oid(self, oid_handler):
69 def test_download_oid(self, oid_handler):
69 store = oid_handler.get_store()
70 store = oid_handler.get_store()
General Comments 0
You need to be logged in to leave comments. Login now