Client/server communication now uses HMAC for auth

This is to prevent a convoluted exploit that can trigger remote code execution.
This commit is contained in:
Strahinja Val Markovic 2014-04-25 11:07:08 -07:00
parent abfc3ee36a
commit e965e02847
5 changed files with 117 additions and 15 deletions

View File

@ -24,6 +24,7 @@ from retries import retries
from requests_futures.sessions import FuturesSession from requests_futures.sessions import FuturesSession
from ycm.unsafe_thread_pool_executor import UnsafeThreadPoolExecutor from ycm.unsafe_thread_pool_executor import UnsafeThreadPoolExecutor
from ycm import vimsupport from ycm import vimsupport
from ycm import utils
from ycm.utils import ToUtf8Json from ycm.utils import ToUtf8Json
from ycm.server.responses import ServerError, UnknownExtraConf from ycm.server.responses import ServerError, UnknownExtraConf
@ -31,6 +32,7 @@ _HEADERS = {'content-type': 'application/json'}
_EXECUTOR = UnsafeThreadPoolExecutor( max_workers = 30 ) _EXECUTOR = UnsafeThreadPoolExecutor( max_workers = 30 )
# Setting this to None seems to screw up the Requests/urllib3 libs. # Setting this to None seems to screw up the Requests/urllib3 libs.
_DEFAULT_TIMEOUT_SEC = 30 _DEFAULT_TIMEOUT_SEC = 30
_HMAC_HEADER = 'x-ycm-hmac'
class BaseRequest( object ): class BaseRequest( object ):
def __init__( self ): def __init__( self ):
@ -88,24 +90,28 @@ class BaseRequest( object ):
timeout = _DEFAULT_TIMEOUT_SEC ): timeout = _DEFAULT_TIMEOUT_SEC ):
def SendRequest( data, handler, method, timeout ): def SendRequest( data, handler, method, timeout ):
if method == 'POST': if method == 'POST':
return BaseRequest.session.post( _BuildUri( handler ), sent_data = ToUtf8Json( data )
data = ToUtf8Json( data ), return BaseRequest.session.post(
headers = _HEADERS, _BuildUri( handler ),
data = sent_data,
headers = BaseRequest._ExtraHeaders( sent_data ),
timeout = timeout ) timeout = timeout )
if method == 'GET': if method == 'GET':
return BaseRequest.session.get( _BuildUri( handler ), return BaseRequest.session.get(
headers = _HEADERS, _BuildUri( handler ),
headers = BaseRequest._ExtraHeaders(),
timeout = timeout ) timeout = timeout )
@retries( 5, delay = 0.5, backoff = 1.5 ) @retries( 5, delay = 0.5, backoff = 1.5 )
def DelayedSendRequest( data, handler, method ): def DelayedSendRequest( data, handler, method ):
if method == 'POST': if method == 'POST':
sent_data = ToUtf8Json( data )
return requests.post( _BuildUri( handler ), return requests.post( _BuildUri( handler ),
data = ToUtf8Json( data ), data = sent_data,
headers = _HEADERS ) headers = BaseRequest._ExtraHeaders( sent_data ) )
if method == 'GET': if method == 'GET':
return requests.get( _BuildUri( handler ), return requests.get( _BuildUri( handler ),
headers = _HEADERS ) headers = BaseRequest._ExtraHeaders() )
if not _CheckServerIsHealthyWithCache(): if not _CheckServerIsHealthyWithCache():
return _EXECUTOR.submit( DelayedSendRequest, data, handler, method ) return _EXECUTOR.submit( DelayedSendRequest, data, handler, method )
@ -113,8 +119,18 @@ class BaseRequest( object ):
return SendRequest( data, handler, method, timeout ) return SendRequest( data, handler, method, timeout )
@staticmethod
def _ExtraHeaders( request_body = None ):
if not request_body:
request_body = ''
headers = dict( _HEADERS )
headers[ _HMAC_HEADER ] = utils.CreateHexHmac( request_body,
BaseRequest.hmac_secret )
return headers
session = FuturesSession( executor = _EXECUTOR ) session = FuturesSession( executor = _EXECUTOR )
server_location = 'http://localhost:6666' server_location = 'http://localhost:6666'
hmac_secret = ''
def BuildRequestData( start_column = None, def BuildRequestData( start_column = None,
@ -141,6 +157,7 @@ def BuildRequestData( start_column = None,
def JsonFromFuture( future ): def JsonFromFuture( future ):
response = future.result() response = future.result()
_ValidateResponseObject( response )
if response.status_code == requests.codes.server_error: if response.status_code == requests.codes.server_error:
_RaiseExceptionForData( response.json() ) _RaiseExceptionForData( response.json() )
@ -153,6 +170,13 @@ def JsonFromFuture( future ):
return None return None
def _ValidateResponseObject( response ):
if not utils.ContentHexHmacValid( response.content,
response.headers[ _HMAC_HEADER ],
BaseRequest.hmac_secret ):
raise RuntimeError( 'Received invalid HMAC for response!' )
return True
def _BuildUri( handler ): def _BuildUri( handler ):
return urlparse.urljoin( BaseRequest.server_location, handler ) return urlparse.urljoin( BaseRequest.server_location, handler )
@ -163,7 +187,9 @@ def _CheckServerIsHealthyWithCache():
global SERVER_HEALTHY global SERVER_HEALTHY
def _ServerIsHealthy(): def _ServerIsHealthy():
response = requests.get( _BuildUri( 'healthy' ) ) response = requests.get( _BuildUri( 'healthy' ),
headers = BaseRequest._ExtraHeaders() )
_ValidateResponseObject( response )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()

View File

@ -0,0 +1,57 @@
#!/usr/bin/env python
#
# Copyright (C) 2014 Google Inc.
#
# This file is part of YouCompleteMe.
#
# YouCompleteMe is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# YouCompleteMe is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with YouCompleteMe. If not, see <http://www.gnu.org/licenses/>.
import logging
import httplib
from bottle import request, response, abort
from ycm import utils
_HMAC_HEADER = 'x-ycm-hmac'
# This class implements the Bottle plugin API:
# http://bottlepy.org/docs/dev/plugindev.html
#
# We want to ensure that every request coming in has a valid HMAC set in the
# x-ycm-hmac header and that every response coming out sets such a valid header.
# This is to prevent security issues with possible remote code execution.
class HmacPlugin( object ):
name = 'hmac'
api = 2
def __init__( self, hmac_secret ):
self._hmac_secret = hmac_secret
self._logger = logging.getLogger( __name__ )
def __call__( self, callback ):
def wrapper( *args, **kwargs ):
body = request.body.read()
if not utils.ContentHexHmacValid( body,
request.headers[ _HMAC_HEADER ],
self._hmac_secret ):
self._logger.info( 'Dropping request with bad HMAC.' )
abort( httplib.UNAUTHORIZED, 'Unauthorized, received bad HMAC.')
return
body = callback( *args, **kwargs )
response.headers[ _HMAC_HEADER ] = utils.CreateHexHmac(
body, self._hmac_secret )
return body
return wrapper

View File

@ -27,10 +27,12 @@ import argparse
import waitress import waitress
import signal import signal
import os import os
import base64
from ycm import user_options_store from ycm import user_options_store
from ycm import extra_conf_store from ycm import extra_conf_store
from ycm import utils from ycm import utils
from ycm.server.watchdog_plugin import WatchdogPlugin from ycm.server.watchdog_plugin import WatchdogPlugin
from ycm.server.hmac_plugin import HmacPlugin
def YcmCoreSanityCheck(): def YcmCoreSanityCheck():
if 'ycm_core' in sys.modules: if 'ycm_core' in sys.modules:
@ -103,6 +105,8 @@ def Main():
options = ( json.load( open( args.options_file, 'r' ) ) options = ( json.load( open( args.options_file, 'r' ) )
if args.options_file if args.options_file
else user_options_store.DefaultOptions() ) else user_options_store.DefaultOptions() )
utils.RemoveIfExists( args.options_file )
hmac_secret = base64.b64decode( options[ 'hmac_secret' ] )
user_options_store.SetAll( options ) user_options_store.SetAll( options )
# This ensures that ycm_core is not loaded before extra conf # This ensures that ycm_core is not loaded before extra conf
@ -126,6 +130,7 @@ def Main():
handlers.UpdateUserOptions( options ) handlers.UpdateUserOptions( options )
SetUpSignalHandler(args.stdout, args.stderr, args.keep_logfiles) SetUpSignalHandler(args.stdout, args.stderr, args.keep_logfiles)
handlers.app.install( WatchdogPlugin( args.idle_suicide_seconds ) ) handlers.app.install( WatchdogPlugin( args.idle_suicide_seconds ) )
handlers.app.install( HmacPlugin( hmac_secret ) )
waitress.serve( handlers.app, waitress.serve( handlers.app,
host = args.host, host = args.host,
port = args.port, port = args.port,

View File

@ -25,6 +25,8 @@ import functools
import socket import socket
import stat import stat
import json import json
import hmac
import hashlib
from distutils.spawn import find_executable from distutils.spawn import find_executable
import subprocess import subprocess
import collections import collections
@ -212,3 +214,11 @@ def SafePopen( *args, **kwargs ):
return subprocess.Popen( *args, **kwargs ) return subprocess.Popen( *args, **kwargs )
def ContentHexHmacValid( content, hmac, hmac_secret ):
return hmac == CreateHexHmac( content, hmac_secret )
def CreateHexHmac( content, hmac_secret ):
return hmac.new( hmac_secret,
msg = content,
digestmod = hashlib.sha256 ).hexdigest()

View File

@ -22,6 +22,7 @@ import vim
import tempfile import tempfile
import json import json
import signal import signal
import base64
from subprocess import PIPE from subprocess import PIPE
from ycm import vimsupport from ycm import vimsupport
from ycm import utils from ycm import utils
@ -58,6 +59,7 @@ os.environ['no_proxy'] = '127.0.0.1,localhost'
# Ctrl-C in Vim. # Ctrl-C in Vim.
signal.signal( signal.SIGINT, signal.SIG_IGN ) signal.signal( signal.SIGINT, signal.SIG_IGN )
HMAC_SECRET_LENGTH = 16
NUM_YCMD_STDERR_LINES_ON_CRASH = 30 NUM_YCMD_STDERR_LINES_ON_CRASH = 30
SERVER_CRASH_MESSAGE_STDERR_FILE = ( SERVER_CRASH_MESSAGE_STDERR_FILE = (
'The ycmd server SHUT DOWN (restart with :YcmRestartServer). ' + 'The ycmd server SHUT DOWN (restart with :YcmRestartServer). ' +
@ -80,16 +82,18 @@ class YouCompleteMe( object ):
self._server_stderr = None self._server_stderr = None
self._server_popen = None self._server_popen = None
self._filetypes_with_keywords_loaded = set() self._filetypes_with_keywords_loaded = set()
self._temp_options_filename = None
self._ycmd_keepalive = YcmdKeepalive() self._ycmd_keepalive = YcmdKeepalive()
self._SetupServer() self._SetupServer()
self._ycmd_keepalive.Start() self._ycmd_keepalive.Start()
def _SetupServer( self ): def _SetupServer( self ):
server_port = utils.GetUnusedLocalhostPort() server_port = utils.GetUnusedLocalhostPort()
# The temp options file is deleted by ycmd during startup
with tempfile.NamedTemporaryFile( delete = False ) as options_file: with tempfile.NamedTemporaryFile( delete = False ) as options_file:
self._temp_options_filename = options_file.name hmac_secret = os.urandom( HMAC_SECRET_LENGTH )
json.dump( dict( self._user_options ), options_file ) options_dict = dict( self._user_options )
options_dict[ 'hmac_secret' ] = base64.b64encode( hmac_secret )
json.dump( options_dict, options_file )
options_file.flush() options_file.flush()
args = [ utils.PathToPythonInterpreter(), args = [ utils.PathToPythonInterpreter(),
@ -116,6 +120,7 @@ class YouCompleteMe( object ):
self._server_popen = utils.SafePopen( args, stdout = PIPE, stderr = PIPE) self._server_popen = utils.SafePopen( args, stdout = PIPE, stderr = PIPE)
BaseRequest.server_location = 'http://localhost:' + str( server_port ) BaseRequest.server_location = 'http://localhost:' + str( server_port )
BaseRequest.hmac_secret = hmac_secret
self._NotifyUserIfServerCrashed() self._NotifyUserIfServerCrashed()
@ -148,7 +153,6 @@ class YouCompleteMe( object ):
def _ServerCleanup( self ): def _ServerCleanup( self ):
if self._IsServerAlive(): if self._IsServerAlive():
self._server_popen.terminate() self._server_popen.terminate()
utils.RemoveIfExists( self._temp_options_filename )
def RestartServer( self ): def RestartServer( self ):