image_vise 0.0.17 → 0.0.19

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 6b6177fda88bf4453fffa272bf1f824865827fb0
4
- data.tar.gz: a2c9d53049369496b9dd1ec781bda29ca809397b
3
+ metadata.gz: 5fd7e2b6f4a49ab832e600a46708d2974b74ed17
4
+ data.tar.gz: 1b13d15d9e99308a001ee62aeed21bf3e45c7429
5
5
  SHA512:
6
- metadata.gz: 585a4c85b023af887d0e4d508956a864addef958d634baaebf6d84a7639e4d423a16b30f1c59e12e2fd4cb03017118fbb5721af0be7b04a366e6114bc7924fbb
7
- data.tar.gz: 049f638ebf0f16a6c8f70df0df0e6ed55392c347e5467ada234b09962f164841f8bfda4741c4e5c767f7b70f2c1a6b0d55de7439783269d3c7140eb9bbdcdde5
6
+ metadata.gz: 04fcaa4b8fba508845ba29634636edf9f67758d21687754d9d2117d82eefb22282cb22a678de26f4f0e31980a509beb9f6e62b229e08ebfe6dc1b2d5718f83b6
7
+ data.tar.gz: 6ac6c1bda81dbfa6b2ce27106277d016ff725ac80a51a116cba447a51fc878cb89393110e53f4c06befc65d617f041d6fc865a824103c1b9d8803ec776408989
data/README.md CHANGED
@@ -57,7 +57,6 @@ You might want to define a helper method for generating signed URLs as well, whi
57
57
 
58
58
  def thumb_url(source_image_url)
59
59
  qs_params = ImageVise.image_params(src_url: source_image_url, secret: ENV.fetch('IMAGE_VISE_SECRET')) do |pipe|
60
- # For example, you can also yield `pipeline` to the caller
61
60
  pipe.fit_crop width: 256, height: 256, gravity: 'c'
62
61
  pipe.sharpen sigma: 0.5, radius: 2
63
62
  pipe.ellipse_stencil
@@ -66,6 +65,15 @@ You might want to define a helper method for generating signed URLs as well, whi
66
65
  '/images?' + Rack::Utils.build_query(image_request)
67
66
  end
68
67
 
68
+
69
+ ## Processing files on the local filesystem instead of remote ones
70
+
71
+ If you want to grab a local file, compose a `file://` URL (mind the endcoding!)
72
+
73
+ src_url = 'file://' + URI.encode(File.expand_path(my_pic))
74
+
75
+ Note that you need to permit certain glob patterns as sources before this will work, see below.
76
+
69
77
  ## Operators and pipelining
70
78
 
71
79
  ImageVise processes an image using _operators_. Each operator is just like an adjustment layer in Photoshop, except
@@ -160,26 +168,25 @@ multiple applications all using different keys for their signatures. Every reque
160
168
  each key and if at least one key generates the same signature for the same given parameters, it is going to be
161
169
  accepted and the request will be allowed to go through.
162
170
 
163
- ## Hostname validation
171
+ ## Hostname and filesystem validation
164
172
 
165
173
  By default, `ImageVise` will refuse to process images from URLs on "unknown" hosts. To mark a host as "known"
166
174
  tell `ImageVise` to
167
175
 
168
176
  ImageVise.add_allowed_host!('my-image-store.ourcompany.co.uk')
169
177
 
178
+ If you want to permit images from the local server filesystem to be accessed, add the glob pattern
179
+ to the set of allowed filesystem patterns:
180
+
181
+ ImageVise.allow_filesystem_source!(Rails.root + '/public/*.jpg')
182
+
183
+ Note that these are _glob_ patterns. The image path will be checked against them using `File.fnmatch`.
184
+
170
185
  ## State
171
186
 
172
187
  Except for the HTTP cache for redirects et.al no state is stored (`ImageVise` does not care whether you store
173
188
  your images using Dragonfly, CarrierWave or some custom handling code). All the app needs is the full URL.
174
189
 
175
- ## FAQ
176
-
177
- * _Yo dawg, I thought you like URLs so I have put encoded URL in your URL so you can..._ - well, the only alternative
178
- is also managing image storage, and this something we want to avoid to keep `ImageVise` stateless
179
- * _But the URLs can be exploited_ - this is highly unlikely if you pick strong keys for the HMAC signatures
180
- * _I can load any image into the thumbnailer_ - in fact, no. First you have the URL checks, and then - all the URLs
181
- are supposed to be coming from the sources you trust since they are signed.
182
-
183
190
  ## Running the tests, versioning, contributing
184
191
 
185
192
  By default, `bundle exec rake` will run RSpec and will also open the generated images using the `$ open` command available
data/image_vise.gemspec CHANGED
@@ -2,11 +2,11 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: image_vise 0.0.17 ruby lib
5
+ # stub: image_vise 0.0.19 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "image_vise"
9
- s.version = "0.0.17"
9
+ s.version = "0.0.19"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
data/lib/image_vise.rb CHANGED
@@ -3,45 +3,63 @@ require 'json'
3
3
  require 'patron'
4
4
  require 'rmagick'
5
5
  require 'magic_bytes'
6
+ require 'thread'
6
7
 
7
8
  class ImageVise
8
- VERSION = '0.0.17'
9
-
9
+ VERSION = '0.0.19'
10
+ S_MUTEX = Mutex.new
11
+ private_constant :S_MUTEX
12
+
10
13
  @allowed_hosts = Set.new
11
14
  @keys = Set.new
12
15
  @operators = {}
13
-
16
+ @allowed_glob_patterns = Set.new
17
+
14
18
  class << self
15
19
  # Resets all allowed hosts
16
20
  def reset_allowed_hosts!
17
- @allowed_hosts.clear
21
+ S_MUTEX.synchronize { @allowed_hosts.clear }
18
22
  end
19
23
 
20
24
  # Add an allowed host
21
25
  def add_allowed_host!(hostname)
22
- @allowed_hosts << hostname
26
+ S_MUTEX.synchronize { @allowed_hosts << hostname }
23
27
  end
24
28
 
25
29
  # Returns both the allowed hosts added at runtime and the ones set in the constant
26
30
  def allowed_hosts
27
- @allowed_hosts.to_a
31
+ S_MUTEX.synchronize { @allowed_hosts.to_a }
28
32
  end
29
33
 
30
34
  # Removes all set keys
31
35
  def reset_secret_keys!
32
- @keys.clear
36
+ S_MUTEX.synchronize { @keys.clear }
33
37
  end
34
38
 
39
+ def allow_filesystem_source!(glob_pattern)
40
+ S_MUTEX.synchronize { @allowed_glob_patterns << glob_pattern }
41
+ end
42
+
43
+ def allowed_filesystem_sources
44
+ S_MUTEX.synchronize { @allowed_glob_patterns.to_a }
45
+ end
46
+
47
+ def deny_filesystem_sources!
48
+ S_MUTEX.synchronize { @allowed_glob_patterns.clear }
49
+ end
50
+
35
51
  # Adds a key against which the parameters are going to be verified.
36
52
  # Multiple applications may have their own different keys,
37
53
  # so we need to have multiple keys.
38
54
  def add_secret_key!(key)
39
- @keys << key; self
55
+ S_MUTEX.synchronize { @keys << key }
56
+ self
40
57
  end
41
58
 
42
59
  # Returns the array of defined keys or raises an exception if no keys have been set yet
43
60
  def secret_keys
44
- (@keys.any? && @keys.to_a) or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
61
+ keys = S_MUTEX.synchronize { @keys.any? && @keys.to_a }
62
+ keys or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
45
63
  end
46
64
 
47
65
  # Generate a set of querystring params for a resized image. Yields a Pipeline object that
@@ -1,4 +1,5 @@
1
1
  require 'base64'
2
+ require 'rack'
2
3
 
3
4
  class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
4
5
  class InvalidRequest < ArgumentError; end
@@ -8,7 +9,7 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
8
9
 
9
10
  # Initializes a new ParamsChecker from given HTTP server framework
10
11
  # params. The params can be symbol- or string-keyed, does not matter.
11
- def self.to_request(qs_params:, secrets:, permitted_source_hosts:)
12
+ def self.to_request(qs_params:, secrets:, permitted_source_hosts:, allowed_filesystem_patterns:)
12
13
  base64_encoded_params = qs_params.fetch(:q) rescue qs_params.fetch('q')
13
14
  given_signature = qs_params.fetch(:sig) rescue qs_params.fetch('sig')
14
15
 
@@ -24,12 +25,19 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
24
25
  # Pick up the URL and validate it
25
26
  src_url = params.fetch(:src_url).to_s
26
27
  raise URLError, "the :src_url parameter must be non-empty" if src_url.empty?
27
- raise URLError, "#{src_url} is not permitted as source" unless valid_host?(src_url, permitted_source_hosts)
28
28
 
29
+ src_url = URI.parse(src_url)
30
+ if src_url.scheme == 'file'
31
+ raise URLError, "#{src_url} not permitted since filesystem access is disabled" if allowed_filesystem_patterns.empty?
32
+ raise URLError, "#{src_url} is not on the path whitelist" unless allowed_path?(allowed_filesystem_patterns, src_url.path)
33
+ elsif src_url.scheme != 'file'
34
+ raise URLError, "#{src_url} is not permitted as source" unless permitted_source_hosts.include?(src_url.host)
35
+ end
36
+
29
37
  # Build out the processing pipeline
30
38
  pipeline_definition = params.fetch(:pipeline)
31
39
 
32
- new(src_url: src_url, pipeline: ImageVise::Pipeline.from_param(pipeline_definition))
40
+ new(src_url: src_url.to_s, pipeline: ImageVise::Pipeline.from_param(pipeline_definition))
33
41
  rescue KeyError => e
34
42
  raise InvalidRequest.new(e.message)
35
43
  end
@@ -49,6 +57,11 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
49
57
 
50
58
  private
51
59
 
60
+ def self.allowed_path?(filesystem_glob_patterns, path_to_check)
61
+ expanded_path = File.expand_path(path_to_check)
62
+ filesystem_glob_patterns.any? {|pattern| File.fnmatch?(pattern, expanded_path) }
63
+ end
64
+
52
65
  def self.valid_signature?(for_payload, given_signature, secrets)
53
66
  # Check the signature against every key that we have,
54
67
  # since different apps might be using different keys
@@ -60,9 +73,4 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
60
73
  end
61
74
  seen_valid_signature
62
75
  end
63
-
64
- def self.valid_host?(src_url, permitted_hosts)
65
- parsed_url = URI.parse(src_url)
66
- permitted_hosts.include?(parsed_url.host)
67
- end
68
76
  end
@@ -35,20 +35,12 @@ class ImageVise::RenderEngine
35
35
 
36
36
  # Fetch the given URL into a Tempfile and return the File object
37
37
  def fetch_url_into_tempfile(source_image_uri)
38
- tf = Tempfile.new('source-imagevise-image')
39
- s = Patron::Session.new
40
- s.automatic_content_encoding = true
41
- s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
42
- s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
43
- response = s.get_file(source_image_uri, tf.path)
44
- if PASSTHROUGH_STATUS_CODES.include?(response.status)
45
- tf.close; tf.unlink;
46
- bail response.status, "Unfortunate upstream response: #{response.status}"
38
+ parsed = URI.parse(source_image_uri)
39
+ if parsed.scheme == 'file'
40
+ copy_path_into_tempfile(URI.decode(parsed.path))
41
+ else
42
+ fetch_url(source_image_uri)
47
43
  end
48
- tf
49
- rescue Exception => e
50
- tf.close; tf.unlink;
51
- raise e
52
44
  end
53
45
 
54
46
  def bail(status, *errors_array)
@@ -76,9 +68,7 @@ class ImageVise::RenderEngine
76
68
  bail(405, 'Only GET supported') unless req.get?
77
69
 
78
70
  # Validate the inputs
79
- image_request = ImageVise::ImageRequest.to_request(qs_params: req.params,
80
- secrets: ImageVise.secret_keys,
81
- permitted_source_hosts: ImageVise.allowed_hosts)
71
+ image_request = ImageVise::ImageRequest.to_request(qs_params: req.params, **image_request_options)
82
72
 
83
73
  # Recover the source image URL and the pipeline instructions (all the image ops)
84
74
  source_image_uri, pipeline = image_request.src_url, image_request.pipeline
@@ -206,5 +196,43 @@ class ImageVise::RenderEngine
206
196
  ensure
207
197
  ImageVise.destroy(magick_image)
208
198
  end
209
-
199
+
200
+ def image_request_options
201
+ {
202
+ secrets: ImageVise.secret_keys,
203
+ permitted_source_hosts: ImageVise.allowed_hosts,
204
+ allowed_filesystem_patterns: ImageVise.allowed_filesystem_sources,
205
+ }
206
+ end
207
+
208
+ def fetch_url(source_image_uri)
209
+ tf = binary_tempfile
210
+ s = Patron::Session.new
211
+ s.automatic_content_encoding = true
212
+ s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
213
+ s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
214
+ response = s.get_file(source_image_uri, tf.path)
215
+ if PASSTHROUGH_STATUS_CODES.include?(response.status)
216
+ tf.close; tf.unlink;
217
+ bail response.status, "Unfortunate upstream response: #{response.status}"
218
+ end
219
+ tf
220
+ rescue Exception => e
221
+ tf.close; tf.unlink;
222
+ raise e
223
+ end
224
+
225
+ def copy_path_into_tempfile(path_on_filesystem)
226
+ tf = binary_tempfile
227
+ File.open(path_on_filesystem, 'rb') do |f|
228
+ IO.copy_stream(f, tf)
229
+ end
230
+ tf.rewind; tf
231
+ rescue Errno::ENOENT
232
+ bail 404, "Image file not found"
233
+ rescue Exception => e
234
+ tf.close; tf.unlink;
235
+ raise e
236
+ end
237
+
210
238
  end
@@ -10,11 +10,43 @@ describe ImageVise::ImageRequest do
10
10
  sig: signature
11
11
  }
12
12
 
13
- image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
13
+ image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'],
14
+ permitted_source_hosts: ['bucket.s3.aws.com'], allowed_filesystem_patterns: [])
14
15
  request_qs_params = image_request.to_query_string_params('this is a secret')
15
16
  expect(request_qs_params).to be_kind_of(Hash)
16
17
 
17
- image_request_roundtrip = described_class.to_request(qs_params: request_qs_params, secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'])
18
+ image_request_roundtrip = described_class.to_request(qs_params: request_qs_params,
19
+ secrets: ['this is a secret'], permitted_source_hosts: ['bucket.s3.aws.com'], allowed_filesystem_patterns: [])
20
+ end
21
+
22
+ it 'forbids a file:// URL if the flag is not enabled' do
23
+ img_params = {src_url: 'file:///etc/passwd', pipeline: [[:auto_orient]]}
24
+ img_params_json = JSON.dump(img_params)
25
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', img_params_json)
26
+ params = {
27
+ q: Base64.encode64(img_params_json),
28
+ sig: signature
29
+ }
30
+
31
+ expect {
32
+ described_class.to_request(qs_params: params, secrets: ['this is a secret'],
33
+ permitted_source_hosts: ['bucket.s3.aws.com'], allowed_filesystem_patterns: [])
34
+ }.to raise_error(/filesystem access is disabled/)
35
+ end
36
+
37
+ it 'allows a file:// URL if its path is within the permit list' do
38
+ img_params = {src_url: 'file:///etc/passwd', pipeline: [[:auto_orient, {}]]}
39
+ img_params_json = JSON.dump(img_params)
40
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', img_params_json)
41
+ params = {
42
+ q: Base64.encode64(img_params_json),
43
+ sig: signature
44
+ }
45
+
46
+ image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'],
47
+ permitted_source_hosts: ['bucket.s3.aws.com'], allowed_filesystem_patterns: %w( /etc/* ))
48
+ request_qs_params = image_request.to_query_string_params('this is a secret')
49
+ expect(request_qs_params).to be_kind_of(Hash)
18
50
  end
19
51
 
20
52
  describe 'fails with an invalid pipeline' do
@@ -46,7 +78,7 @@ describe ImageVise::ImageRequest do
46
78
 
47
79
  expect {
48
80
  described_class.to_request(qs_params: params,
49
- secrets: ['b'], permitted_source_hosts: ['bucket.s3.aws.com'])
81
+ secrets: ['b'], permitted_source_hosts: ['bucket.s3.aws.com'], allowed_filesystem_patterns: [])
50
82
  }.to raise_error(/Invalid or missing signature/)
51
83
  end
52
84
  end
@@ -30,38 +30,50 @@ describe ImageVise::RenderEngine do
30
30
  end
31
31
 
32
32
  context 'when requesting an image' do
33
+ before :each do
34
+ parsed_url = Addressable::URI.parse(public_url)
35
+ ImageVise.add_allowed_host!(parsed_url.host)
36
+ end
37
+
33
38
  after :each do
34
39
  ImageVise.reset_allowed_hosts!
35
40
  ImageVise.reset_secret_keys!
36
41
  end
37
42
 
38
- context 'halts with 422' do
39
- before :each do
40
- parsed_url = Addressable::URI.parse(public_url)
41
- ImageVise.add_allowed_host!(parsed_url.host)
42
- end
43
+ it 'halts with 422 when the requested image cannot be opened by ImageMagick' do
44
+ uri = Addressable::URI.parse(public_url)
45
+ ImageVise.add_allowed_host!(uri.host)
46
+ ImageVise.add_secret_key!('l33tness')
47
+ uri.path = '/___nonexistent_image.jpg'
48
+
49
+ p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
50
+ image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
51
+ params = image_request.to_query_string_params('l33tness')
52
+
53
+ expect_any_instance_of(Patron::Session).to receive(:get_file) {|_self, url, path|
54
+ File.open(path, 'wb') {|f| f << 'totally not an image' }
55
+ double(status: 200)
56
+ }
57
+ expect(app).to receive(:handle_request_error).and_call_original
58
+
59
+ get '/', params
60
+ expect(last_response.status).to eq(422)
61
+ expect(last_response['Cache-Control']).to eq("private, max-age=0, no-cache")
62
+ expect(last_response.body).to include('Unsupported/unknown')
63
+ end
43
64
 
44
- it 'when the requested image cannot be opened by ImageMagick' do
45
- uri = Addressable::URI.parse(public_url)
46
- ImageVise.add_allowed_host!(uri.host)
47
- ImageVise.add_secret_key!('l33tness')
48
- uri.path = '/___nonexistent_image.jpg'
49
-
50
- p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
51
- image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
52
- params = image_request.to_query_string_params('l33tness')
53
-
54
- expect_any_instance_of(Patron::Session).to receive(:get_file) {|_self, url, path|
55
- File.open(path, 'wb') {|f| f << 'totally not an image' }
56
- double(status: 200)
57
- }
58
- expect(app).to receive(:handle_request_error).and_call_original
59
-
60
- get '/', params
61
- expect(last_response.status).to eq(422)
62
- expect(last_response['Cache-Control']).to eq("private, max-age=0, no-cache")
63
- expect(last_response.body).to include('Unsupported/unknown')
64
- end
65
+ it 'halts with 422 when a file:// URL is given and filesystem access is not enabled' do
66
+ uri = 'file://' + test_image_path
67
+ ImageVise.deny_filesystem_sources!
68
+ ImageVise.add_secret_key!('l33tness')
69
+
70
+ p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
71
+ image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
72
+ params = image_request.to_query_string_params('l33tness')
73
+
74
+ get '/', params
75
+ expect(last_response.status).to eq(422)
76
+ expect(last_response.body).to include('filesystem access is disabled')
65
77
  end
66
78
 
67
79
  it 'responds with 403 when upstream returns it' do
@@ -145,7 +157,41 @@ describe ImageVise::RenderEngine do
145
157
  parsed_image = Magick::Image.from_blob(last_response.body)[0]
146
158
  expect(parsed_image.columns).to eq(10)
147
159
  end
148
-
160
+
161
+ it 'picks the image from the filesystem if that is permitted' do
162
+ uri = 'file://' + test_image_path
163
+ ImageVise.allow_filesystem_source!(File.dirname(test_image_path) + '/*.*')
164
+ ImageVise.add_secret_key!('l33tness')
165
+
166
+ p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
167
+ image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
168
+ params = image_request.to_query_string_params('l33tness')
169
+
170
+ get '/', params
171
+ expect(last_response.status).to eq(200)
172
+ expect(last_response.headers['Content-Type']).to eq('image/jpeg')
173
+ end
174
+
175
+ it 'expands and forbids a path outside of the permitted sources'
176
+
177
+ it 'URI-decodes the path in a file:// URL for a file with a Unicode path' do
178
+ utf8_file_path = File.dirname(test_image_path) + '/картинка.jpg'
179
+ FileUtils.cp_r(test_image_path, utf8_file_path)
180
+ uri = 'file://' + URI.encode(utf8_file_path)
181
+
182
+ ImageVise.allow_filesystem_source!(File.dirname(test_image_path) + '/*.*')
183
+ ImageVise.add_secret_key!('l33tness')
184
+
185
+ p = ImageVise::Pipeline.new.fit_crop(width: 10, height: 10, gravity: 'c')
186
+ image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
187
+ params = image_request.to_query_string_params('l33tness')
188
+
189
+ get '/', params
190
+ File.unlink(utf8_file_path)
191
+ expect(last_response.status).to eq(200)
192
+ expect(last_response.headers['Content-Type']).to eq('image/jpeg')
193
+ end
194
+
149
195
  it 'returns the processed JPEG image as a PNG if it had to get an alpha channel during processing' do
150
196
  uri = Addressable::URI.parse(public_url)
151
197
  ImageVise.add_allowed_host!(uri.host)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: image_vise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.17
4
+ version: 0.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov