camo-rb 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b410ac3bfb745c4cb2ef0aaa82f435c465258ec7aa888ec1dce17e767d132307
4
+ data.tar.gz: 06110020de592ca9708ab8fc6c341cc1c569cae0c3bc26263e1670e16b18c578
5
+ SHA512:
6
+ metadata.gz: 1edb89a1a15b0fd13071bbe511253e71f975858f37078ec699f2c1319d32c05303ae860018bc5a9bc3d50fcb99b29e854842013353fdf56aa15bc608150810d8
7
+ data.tar.gz: d6dd14481dc67966dbf4bc1c91057272d0f9beaeefa7f718fb46251fd1d2b48851f43a838718945dfc06a403090ea31ac21bbc6e34b1c6b9faeaf0abef222b42
data/bin/camorb ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rack/handler/falcon"
5
+ require_relative "../environment"
6
+
7
+ host = "0.0.0.0"
8
+ port = ENV["CAMORB_PORT"] || 9292
9
+
10
+ trap("SIGINT") do
11
+ puts "\nExiting gracefully..."
12
+ exit
13
+ end
14
+
15
+ begin
16
+ app = Camo::Server.new(ENV["CAMORB_KEY"])
17
+ Rack::Handler::Falcon.run(app, host: host, port: port)
18
+ rescue Camo::Errors::AppError => e
19
+ abort("Error: #{e.message}")
20
+ end
data/bin/generate_url ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "openssl"
5
+
6
+ def encode_url(url)
7
+ url.bytes.map { |byte| "%02x" % byte }.join
8
+ end
9
+
10
+ def digest(url, key)
11
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), key, url)
12
+ end
13
+
14
+ url = String(ARGV[0])
15
+ abort("Error: Wrong format\nExample:\n\nCAMORB_KEY=somekey ./generate_url http://google.com/logo.png") if url.empty?
16
+ key = String(ENV["CAMORB_KEY"])
17
+ abort("Key is required. Use the environment variable `CAMORB_KEY` to define it.") if key.empty?
18
+ result = "/#{digest(url, key)}/#{encode_url(url)}"
19
+ result = "#{ENV["CAMORB_HOST"]}#{result}" if ENV["CAMORB_HOST"]
20
+ puts result
data/bin/rspec ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rspec' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rspec-core", "rspec")
data/lib/camo.rb ADDED
@@ -0,0 +1,10 @@
1
+ require "camo/version"
2
+ require "camo/headers_utils"
3
+ require "camo/request"
4
+ require "camo/mime_type_utils"
5
+ require "camo/logger"
6
+ require "camo/client"
7
+ require "camo/server"
8
+ require "camo/errors"
9
+
10
+ module Camo; end
@@ -0,0 +1,97 @@
1
+ require "faraday"
2
+
3
+ module Camo
4
+ class Client
5
+ include Rack::Utils
6
+ include HeadersUtils
7
+ include MimeTypeUtils
8
+
9
+ ALLOWED_TRANSFERRED_HEADERS = HeaderHash[%w[Host Accept Accept-Encoding]]
10
+ KEEP_ALIVE = ["1", "true", true].include?(ENV.fetch("CAMORB_KEEP_ALIVE", false))
11
+ MAX_REDIRECTS = ENV.fetch("CAMORB_MAX_REDIRECTS", 4)
12
+ SOCKET_TIMEOUT = ENV.fetch("CAMORB_SOCKET_TIMEOUT", 10)
13
+ CONTENT_LENGTH_LIMIT = ENV.fetch("CAMORB_LENGTH_LIMIT", 5242880).to_i
14
+
15
+ attr_reader :logger
16
+
17
+ def initialize(logger = Logger.stdio)
18
+ @logger = logger
19
+ end
20
+
21
+ def get(url, transferred_headers = {}, remaining_redirects = MAX_REDIRECTS)
22
+ logger.debug "Handling request to #{url}", {transferred_headers: transferred_headers, remaining_redirects: remaining_redirects}
23
+
24
+ url = URI.parse(url)
25
+ headers = build_request_headers(transferred_headers, url: url)
26
+ response = get_request(url, headers, timeout: SOCKET_TIMEOUT)
27
+
28
+ logger.debug "Request result", {status: response.status, headers: response.headers, body_bytesize: response.body.bytesize}
29
+
30
+ case response.status
31
+ when redirect?
32
+ redirect(response, headers, remaining_redirects)
33
+ when not_modified?
34
+ [response.status, response.headers]
35
+ else
36
+ validate_response!(response)
37
+ [response.status, response.headers, response.body]
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def validate_response!(response)
44
+ raise Errors::ContentLengthExceededError if response.headers["content-length"].to_i > CONTENT_LENGTH_LIMIT
45
+ content_type = String(response.headers["content-type"])
46
+ raise Errors::EmptyContentTypeError if content_type.empty?
47
+ raise Errors::UnsupportedContentTypeError, content_type unless SUPPORTED_CONTENT_TYPES.include?(content_type)
48
+ end
49
+
50
+ def get_request(url, headers, options = {})
51
+ Faraday.get(url, {}, headers) do |req|
52
+ options.each do |key, value|
53
+ req.options.public_send("#{key}=", value)
54
+ end
55
+ end
56
+ rescue Faraday::TimeoutError
57
+ raise Errors::TimeoutError
58
+ end
59
+
60
+ def redirect(response, headers, remaining_redirects)
61
+ raise Errors::TooManyRedirectsError if remaining_redirects < 0
62
+ new_url = String(response.headers["location"])
63
+ logger.debug "Redirect to #{new_url}", {remaining_redirects: remaining_redirects}
64
+ raise Errors::RedirectWithoutLocationError if new_url.empty?
65
+
66
+ get(new_url, headers, remaining_redirects - 1)
67
+ end
68
+
69
+ def not_modified?
70
+ ->(code) { code === 304 }
71
+ end
72
+
73
+ def redirect?
74
+ ->(code) { [301, 302, 303, 307, 308].include? code }
75
+ end
76
+
77
+ def build_request_headers(headers, url:)
78
+ headers = headers.each_with_object({}) do |header, headers|
79
+ key = header[0].tr("_", "-")
80
+ headers[key] = header[1]
81
+ end
82
+
83
+ headers = headers
84
+ .select { |k, _| ALLOWED_TRANSFERRED_HEADERS.include?(k) }
85
+ .merge(default_request_headers)
86
+
87
+ if String(headers["Host"]).empty?
88
+ headers["Host"] = String(url.host)
89
+ headers["Host"] += ":#{url.port}" unless [80, 443].include?(url.port)
90
+ end
91
+
92
+ headers["Connection"] = KEEP_ALIVE ? "keep-alive" : "close"
93
+
94
+ HeaderHash[headers]
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,49 @@
1
+ module Camo
2
+ module Errors
3
+ class AppError < ::StandardError; end
4
+
5
+ class UndefinedKeyError < AppError
6
+ def initialize(message = "Key is required. Use the environment variable `CAMORB_KEY` to define it.")
7
+ super
8
+ end
9
+ end
10
+
11
+ class ClientError < AppError; end
12
+
13
+ class RedirectWithoutLocationError < ClientError
14
+ def initialize(message = "Redirect with no location")
15
+ super
16
+ end
17
+ end
18
+
19
+ class TooManyRedirectsError < ClientError
20
+ def initialize(message = "Too many redirects")
21
+ super
22
+ end
23
+ end
24
+
25
+ class TimeoutError < ClientError
26
+ def initialize(message = "Request timeout")
27
+ super
28
+ end
29
+ end
30
+
31
+ class ContentLengthExceededError < ClientError
32
+ def initialize(message = "Max Content-Length is exceeded")
33
+ super
34
+ end
35
+ end
36
+
37
+ class UnsupportedContentTypeError < ClientError
38
+ def initialize(content_type, message = "Unsupported Content-Type: '#{content_type}'")
39
+ super(message)
40
+ end
41
+ end
42
+
43
+ class EmptyContentTypeError < ClientError
44
+ def initialize(message = "Empty Content-Type")
45
+ super
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,39 @@
1
+ module Camo
2
+ module HeadersUtils
3
+ HOSTNAME = ENV.fetch("CAMORB_HOSTNAME", "unknown")
4
+ TIMING_ALLOW_ORIGIN = ENV.fetch("CAMORB_TIMING_ALLOW_ORIGIN", nil)
5
+
6
+ REQUEST_SECURITY_HEADERS = {
7
+ "X-Frame-Options" => "deny",
8
+ "X-XSS-Protection" => "1; mode=block",
9
+ "X-Content-Type-Options" => "nosniff",
10
+ "Content-Security-Policy" => "default-src 'none'; img-src data:; style-src 'unsafe-inline'"
11
+ }
12
+
13
+ RESPONSE_SECURITY_HEADERS = REQUEST_SECURITY_HEADERS.merge({
14
+ "Strict-Transport-Security" => "max-age=31536000; includeSubDomains"
15
+ })
16
+
17
+ def self.user_agent
18
+ ENV.fetch("CAMORB_HEADER_VIA", "CamoRB Asset Proxy #{Camo::Version::GEM}")
19
+ end
20
+
21
+ def default_response_headers
22
+ RESPONSE_SECURITY_HEADERS.merge({
23
+ "Camo-Host" => HOSTNAME,
24
+ "Timing-Allow-Origin" => TIMING_ALLOW_ORIGIN
25
+ }).compact
26
+ end
27
+
28
+ def default_request_headers
29
+ REQUEST_SECURITY_HEADERS.merge({
30
+ "Via" => user_agent,
31
+ "User-Agent" => user_agent
32
+ })
33
+ end
34
+
35
+ def user_agent
36
+ HeadersUtils.user_agent
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,73 @@
1
+ module Camo
2
+ class Logger
3
+ attr_reader :outpipe, :errpipe
4
+
5
+ LOG_LEVELS = ["debug", "info", "error", "fatal"].freeze
6
+ LOG_LEVEL = (LOG_LEVELS.find { |level| level == ENV["CAMORB_LOG_LEVEL"] } || "info").freeze
7
+
8
+ def initialize(outpipe, errpipe)
9
+ @outpipe = outpipe
10
+ @errpipe = errpipe
11
+ end
12
+
13
+ def self.stdio
14
+ new $stdout, $stderr
15
+ end
16
+
17
+ def debug(msg, params = {})
18
+ outpipe.puts(compile_output("debug", msg, params)) if debug?
19
+ end
20
+
21
+ def info(msg, params = {})
22
+ outpipe.puts(compile_output("info", msg, params)) if info?
23
+ end
24
+
25
+ def error(msg, params = {})
26
+ errpipe.puts(compile_output("error", msg, params)) if error?
27
+ end
28
+
29
+ private
30
+
31
+ def debug?
32
+ LOG_LEVELS.find_index(LOG_LEVEL) <= LOG_LEVELS.find_index("debug")
33
+ end
34
+
35
+ def info?
36
+ LOG_LEVELS.find_index(LOG_LEVEL) <= LOG_LEVELS.find_index("info")
37
+ end
38
+
39
+ def error?
40
+ LOG_LEVELS.find_index(LOG_LEVEL) <= LOG_LEVELS.find_index("error")
41
+ end
42
+
43
+ def compile_output(level, msg, params)
44
+ output = []
45
+ output << "[#{level.upcase}]"
46
+ output << (msg.is_a?(Array) ? msg.join(", ") : msg)
47
+
48
+ if params.any?
49
+ output << "|"
50
+ output << convert_params_to_string(params)
51
+ end
52
+
53
+ output.join(" ")
54
+ end
55
+
56
+ def convert_params_to_string(params)
57
+ elements = []
58
+
59
+ params.each do |key, value|
60
+ compiled_value =
61
+ if value.is_a?(Hash)
62
+ convert_params_to_string(value)
63
+ else
64
+ "\"#{value}\""
65
+ end
66
+
67
+ elements << "#{key}: #{compiled_value}"
68
+ end
69
+
70
+ "{ #{elements.join(", ")} }"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,49 @@
1
+ module Camo
2
+ module MimeTypeUtils
3
+ SUPPORTED_CONTENT_TYPES = %w[
4
+ image/bmp
5
+ image/cgm
6
+ image/g3fax
7
+ image/gif
8
+ image/ief
9
+ image/jp2
10
+ image/jpeg
11
+ image/jpg
12
+ image/pict
13
+ image/png
14
+ image/prs.btif
15
+ image/svg+xml
16
+ image/tiff
17
+ image/vnd.adobe.photoshop
18
+ image/vnd.djvu
19
+ image/vnd.dwg
20
+ image/vnd.dxf
21
+ image/vnd.fastbidsheet
22
+ image/vnd.fpx
23
+ image/vnd.fst
24
+ image/vnd.fujixerox.edmics-mmr
25
+ image/vnd.fujixerox.edmics-rlc
26
+ image/vnd.microsoft.icon
27
+ image/vnd.ms-modi
28
+ image/vnd.net-fpx
29
+ image/vnd.wap.wbmp
30
+ image/vnd.xiff
31
+ image/webp
32
+ image/x-cmu-raster
33
+ image/x-cmx
34
+ image/x-icon
35
+ image/x-macpaint
36
+ image/x-pcx
37
+ image/x-pict
38
+ image/x-portable-anymap
39
+ image/x-portable-bitmap
40
+ image/x-portable-graymap
41
+ image/x-portable-pixmap
42
+ image/x-quicktime
43
+ image/x-rgb
44
+ image/x-xbitmap
45
+ image/x-xpixmap
46
+ image/x-xwindowdump
47
+ ].freeze
48
+ end
49
+ end
@@ -0,0 +1,78 @@
1
+ require "rack/utils"
2
+ require "addressable/uri"
3
+
4
+ module Camo
5
+ class Request
6
+ include Rack::Utils
7
+ include HeadersUtils
8
+
9
+ SUPPORTED_PROTOCOLS = %w[http https]
10
+
11
+ attr_reader :key, :method, :protocol, :host, :path, :headers, :query_string, :params, :destination_url, :digest, :digest_type, :errors
12
+
13
+ def initialize(env, key)
14
+ @method = env["REQUEST_METHOD"]
15
+ @query_string = env["QUERY_STRING"]
16
+ @params = parse_query(@query_string)
17
+ @protocol = env["rack.url_scheme"] || "http"
18
+ @host = env["HTTP_HOST"]
19
+ @path = env["PATH_INFO"]
20
+ @headers = build_headers(env)
21
+ @key = key
22
+
23
+ @digest, encoded_url = path[1..].split("/", 2).map { |part| String(part) }
24
+
25
+ if encoded_url
26
+ @digest_type = "path"
27
+ @destination_url = Addressable::URI.parse(String(decode_hex(encoded_url)))
28
+ else
29
+ @digest_type = "query"
30
+ @destination_url = Addressable::URI.parse(String(params["url"]))
31
+ end
32
+
33
+ @errors = []
34
+ end
35
+
36
+ def url
37
+ "#{protocol}://#{host}#{path}#{query_string.empty? ? nil : "?#{query_string}"}"
38
+ end
39
+
40
+ def valid_request?
41
+ validate_request
42
+ Array(errors).empty?
43
+ end
44
+
45
+ def valid_digest?
46
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha1"), key, destination_url) == digest
47
+ end
48
+
49
+ private
50
+
51
+ def build_headers(env)
52
+ hash = env.select { |k, _| k.start_with?("HTTP_") }
53
+ hash = hash.each_with_object({}) do |header, headers|
54
+ headers[header[0].sub("HTTP_", "")] = header[1]
55
+ end
56
+
57
+ HeaderHash[hash]
58
+ end
59
+
60
+ def validate_request
61
+ @errors ||= []
62
+
63
+ errors << "Empty URL" if destination_url.empty?
64
+ errors << "Empty host" if !destination_url.empty? && String(destination_url.host).empty?
65
+
66
+ if destination_url.scheme && !SUPPORTED_PROTOCOLS.include?(destination_url.scheme)
67
+ errors << "Unsupported protocol: '#{destination_url.scheme}'"
68
+ end
69
+
70
+ errors << "Recursive request" if headers["VIA"] == user_agent
71
+ errors
72
+ end
73
+
74
+ def decode_hex(str)
75
+ [str].pack("H*")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,79 @@
1
+ require "rack/utils"
2
+ require "openssl"
3
+
4
+ module Camo
5
+ class Server
6
+ include Rack::Utils
7
+ include HeadersUtils
8
+
9
+ ALLOWED_REMOTE_HEADERS = HeaderHash[%w[
10
+ Content-Type
11
+ Cache-Control
12
+ eTag
13
+ Expires
14
+ Last-Modified
15
+ Content-Length
16
+ Content-Encoding
17
+ ].map(&:downcase)]
18
+
19
+ attr_reader :request, :key
20
+
21
+ def initialize(key)
22
+ @key = String(key)
23
+ raise Errors::UndefinedKeyError if @key.empty?
24
+ end
25
+
26
+ def call(env)
27
+ build_request(env)
28
+
29
+ return [404, default_response_headers, []] unless request.method == "GET"
30
+
31
+ unless request.valid_digest?
32
+ logger.error("Invalid digest")
33
+ return [401, default_response_headers, ["Invalid digest"]]
34
+ end
35
+
36
+ unless request.valid_request?
37
+ logger.error(request.errors)
38
+ return [422, default_response_headers, request.errors.join(", ")]
39
+ end
40
+
41
+ logger.debug "Request", {
42
+ type: request.digest_type,
43
+ url: request.url,
44
+ headers: request.headers,
45
+ destination: request.destination_url,
46
+ digest: request.digest
47
+ }
48
+
49
+ status, headers, body = client.get(request.destination_url, request.headers)
50
+ headers = build_response_headers(headers)
51
+ logger.debug "Response", {status: status, headers: headers, body_bytesize: body.bytesize}
52
+
53
+ [status, headers, [body]]
54
+ rescue Errors::ClientError => e
55
+ logger.error(e.message)
56
+ [422, default_response_headers, e.message]
57
+ end
58
+
59
+ private
60
+
61
+ def logger
62
+ @logger ||= Logger.stdio
63
+ end
64
+
65
+ def build_request(env)
66
+ @request ||= Request.new(env, key)
67
+ end
68
+
69
+ def client
70
+ @client ||= Client.new
71
+ end
72
+
73
+ def build_response_headers(headers)
74
+ headers
75
+ .select { |k, _| ALLOWED_REMOTE_HEADERS.include?(k.downcase) }
76
+ .merge(default_response_headers)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,5 @@
1
+ module Camo
2
+ module Version
3
+ GEM = "0.0.1"
4
+ end
5
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: camo-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Vyacheslav Alexeev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-06-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A camo server is a special type of image proxy that proxies non-secure
14
+ images over SSL/TLS, in order to prevent mixed content warnings on secure pages.
15
+ The server works in conjunction with back-end code that rewrites image URLs and
16
+ signs them with an HMAC.
17
+ email: alexeev.corp@gmail.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - bin/camorb
23
+ - bin/generate_url
24
+ - bin/rspec
25
+ - lib/camo.rb
26
+ - lib/camo/client.rb
27
+ - lib/camo/errors.rb
28
+ - lib/camo/headers_utils.rb
29
+ - lib/camo/logger.rb
30
+ - lib/camo/mime_type_utils.rb
31
+ - lib/camo/request.rb
32
+ - lib/camo/server.rb
33
+ - lib/camo/version.rb
34
+ homepage: https://github.com/alexeevit/camo-rb
35
+ licenses:
36
+ - MIT
37
+ metadata: {}
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.2.15
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: An SSL/TLS image proxy that uses HMAC signed URLs.
57
+ test_files: []