camo-rb 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []