├── log └── .gitignore ├── tasks └── .gitignore ├── tmp └── .gitignore ├── Procfile ├── test ├── servers │ ├── crash_request.ru │ ├── octocat.jpg │ ├── ok.ru │ ├── redirect_without_location.ru │ └── not_found.ru ├── app_test.rb └── proxy_test.rb ├── .gitignore ├── AUTHORS ├── test.gemfile ├── package.json ├── .travis.yml ├── Dockerfile ├── test.gemfile.lock ├── Rakefile ├── mime-types.json ├── LICENSE.md ├── CHANGELOG.md ├── app.json ├── README.md ├── server.coffee └── server.js /log/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tasks/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /test/servers/crash_request.ru: -------------------------------------------------------------------------------- 1 | run lambda { |env| 2 | raise "b00m" 3 | } 4 | -------------------------------------------------------------------------------- /test/servers/octocat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedbin/camo/master/test/servers/octocat.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tmp/camouflage.pid 3 | tmp/camo.pid 4 | .node-version 5 | vendor 6 | .bundle -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Rick Olson: https://github.com/technoweenie 2 | Corey Donohoe: https://github.com/atmos 3 | -------------------------------------------------------------------------------- /test.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'rack' 3 | gem 'rest-client', '~>1.3' 4 | gem 'addressable', '~>2.3' 5 | gem 'test-unit' -------------------------------------------------------------------------------- /test/servers/ok.ru: -------------------------------------------------------------------------------- 1 | run lambda { |env| 2 | path = File.expand_path('../octocat.jpg', __FILE__) 3 | data = File.read(path) 4 | [200, {'Content-Type' => 'image/jpeg'}, [data]] 5 | } 6 | -------------------------------------------------------------------------------- /test/servers/redirect_without_location.ru: -------------------------------------------------------------------------------- 1 | class ProxyTestServer 2 | def call(env) 3 | [302, {"Content-Type" => "image/foo"}, "test"] 4 | end 5 | end 6 | 7 | run ProxyTestServer.new 8 | -------------------------------------------------------------------------------- /test/servers/not_found.ru: -------------------------------------------------------------------------------- 1 | run lambda { |env| 2 | path = File.expand_path('../octocat.jpg', __FILE__) 3 | data = File.read(path) 4 | [404, {'Content-Type' => 'image/jpeg'}, [data]] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "camo", 3 | "version": "2.3.0", 4 | "dependencies": {}, 5 | "engines": { 6 | "node": "^6.11.1" 7 | }, 8 | "devDependencies": { 9 | "coffee-script": "^1.12.6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.4 4 | gemfile: test.gemfile 5 | before_install: 6 | - npm install -g coffee-script 7 | - gem install rake 8 | before_script: 9 | - node --version 10 | - npm --version 11 | - coffee server.coffee & 12 | script: rake 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu 2 | 3 | RUN apt-get update && apt-get install -yq nodejs npm 4 | 5 | RUN mkdir /app 6 | WORKDIR /app 7 | 8 | ADD package.json /app/ 9 | RUN npm install 10 | 11 | ADD server.js /app/ 12 | ADD mime-types.json /app/ 13 | 14 | EXPOSE 8081 15 | USER nobody 16 | CMD nodejs server.js 17 | -------------------------------------------------------------------------------- /test/app_test.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'json' 3 | require 'test/unit' 4 | 5 | class CamoAppTest < Test::Unit::TestCase 6 | def test_heroku_app_json 7 | app_file = File.expand_path("../../app.json", __FILE__) 8 | assert_nothing_raised do 9 | JSON.parse(File.read(app_file)) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test.gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.3.4) 5 | mime-types (1.23) 6 | power_assert (0.2.2) 7 | rack (2.0.3) 8 | rest-client (1.6.7) 9 | mime-types (>= 1.16) 10 | test-unit (3.0.8) 11 | power_assert 12 | 13 | PLATFORMS 14 | ruby 15 | 16 | DEPENDENCIES 17 | addressable (~> 2.3) 18 | rack 19 | rest-client (~> 1.3) 20 | test-unit 21 | 22 | BUNDLED WITH 23 | 1.14.6 24 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | file 'server.js' => 'server.coffee' do 2 | sh "coffee -c -o . server.coffee" 3 | end 4 | task :build => 'server.js' 5 | 6 | task :bundle do 7 | sh("bundle install --gemfile test.gemfile") 8 | end 9 | 10 | desc "Run the tests against localhost" 11 | task :test do 12 | sh("BUNDLE_GEMFILE=test.gemfile bundle exec ruby test/proxy_test.rb") 13 | end 14 | 15 | task :default => [:build, :bundle, :test] 16 | 17 | Dir["tasks/*.rake"].each do |f| 18 | load f 19 | end 20 | -------------------------------------------------------------------------------- /mime-types.json: -------------------------------------------------------------------------------- 1 | [ 2 | "image/bmp", 3 | "image/cgm", 4 | "image/g3fax", 5 | "image/gif", 6 | "image/ief", 7 | "image/jp2", 8 | "image/jpeg", 9 | "image/jpg", 10 | "image/pict", 11 | "image/png", 12 | "image/prs.btif", 13 | "image/svg+xml", 14 | "image/tiff", 15 | "image/vnd.adobe.photoshop", 16 | "image/vnd.djvu", 17 | "image/vnd.dwg", 18 | "image/vnd.dxf", 19 | "image/vnd.fastbidsheet", 20 | "image/vnd.fpx", 21 | "image/vnd.fst", 22 | "image/vnd.fujixerox.edmics-mmr", 23 | "image/vnd.fujixerox.edmics-rlc", 24 | "image/vnd.microsoft.icon", 25 | "image/vnd.ms-modi", 26 | "image/vnd.net-fpx", 27 | "image/vnd.wap.wbmp", 28 | "image/vnd.xiff", 29 | "image/webp", 30 | "image/x-cmu-raster", 31 | "image/x-cmx", 32 | "image/x-icon", 33 | "image/x-macpaint", 34 | "image/x-pcx", 35 | "image/x-pict", 36 | "image/x-portable-anymap", 37 | "image/x-portable-bitmap", 38 | "image/x-portable-graymap", 39 | "image/x-portable-pixmap", 40 | "image/x-quicktime", 41 | "image/x-rgb", 42 | "image/x-xbitmap", 43 | "image/x-xpixmap", 44 | "image/x-xwindowdump" 45 | ] 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2014 Corey Donohoe, Rick Olson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2.3.0 2 | ===== 3 | 4 | * Move things out of GitHub's private network, favor heroku. [31](https://github.com/atmos/camo/pull/31) 5 | * Move to node 0.10.21 for security fixes. [36](https://github.com/atmos/camo/pull/36) 6 | * Don't crash on redirection without location header. 7 | * Whitelist content-types rather than blindly accepting `image/*`. [commit](https://github.com/atmos/camo/commit/9f9925ceb9) 8 | * Disable keep-alive connections [70](https://github.com/atmos/camo/pull/70). 9 | * Stop writing pid files [28](https://github.com/atmos/camo/pull/28) 10 | 11 | 1.1.3 12 | ===== 13 | 14 | * [Address ddos](https://groups.google.com/forum/#!msg/nodejs/NEbweYB0ei0/gWvyzCunYjsJ?mkt_tok=3RkMMJWWfF9wsRonuavPZKXonjHpfsX54%2B8tXaO3lMI%2F0ER3fOvrPUfGjI4ASMFrI%2BSLDwEYGJlv6SgFQrjAMapmyLgLUhE%3D) in earlier versions of node. 15 | 16 | 1.1.1 17 | ===== 18 | 19 | * Use pipe() to pause buffers when streaming to slow clients 20 | * Fixup tests and Gemfile related stuff 21 | * Workaround recent heroku changes that now detect camo as a ruby app due to Gemfile presence 22 | * Ensure a location header is present before following redirects, fixes a crash 23 | 24 | 1.0.5 25 | ===== 26 | 27 | * Fixup redirect loops where following redirects goes back to camo 28 | * Add Fallback Accept headers for type `image/*` 29 | * Fixup issues with chunked encoding responses 30 | * Explicitly set User-Agent headers when proxying 31 | 32 | 1.0.2 33 | ===== 34 | 35 | * Follow 303s and 307s now too 36 | 37 | 0.5.0 38 | ===== 39 | 40 | * Follow redirects to a configurable depth 41 | 42 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "camo", 3 | "logo": "https://camo.githubusercontent.com/4d04abe0044d94fefcf9af21332239dbebf01ded/68747470733a2f2f662e636c6f75642e6769746875622e636f6d2f6173736574732f33382f323439363137322f66353538626262342d623331322d313165332d383865392d3634366237376534376536652e676966", 4 | "description": "Camo is all about making insecure image assets look secure.", 5 | 6 | "keywords": [ 7 | "ssl", 8 | "image", 9 | "proxy", 10 | "github", 11 | "anonymous" 12 | ], 13 | 14 | "website": "http://github.com/atmos/camo", 15 | "repository": "https://github.com/atmos/camo", 16 | "success_url": "/status", 17 | 18 | "env": { 19 | "CAMO_HOSTNAME": { 20 | "description": "The hostname for the camo server.", 21 | "required": false 22 | }, 23 | "CAMO_KEY": { 24 | "description": "The fully qualified domain name for camo to run on.", 25 | "generator": "secret" 26 | }, 27 | "CAMO_LENGTH_LIMIT": { 28 | "description": "The maximum Content-Length that camo will proxy in bytes", 29 | "value": "5242880" 30 | }, 31 | "CAMO_LOGGING_ENABLED": { 32 | "description": "Toggle whether or not to log verbosely('debug' or disabled').", 33 | "required": false 34 | }, 35 | "CAMO_MAX_REDIRECTS": { 36 | "description": "The number of redirects that camo should follow", 37 | "value": "4" 38 | }, 39 | "CAMO_SOCKET_TIMEOUT": { 40 | "description": "The number of seconds to wait for socket connection errors", 41 | "value": "10" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # camo [![Build Status](https://travis-ci.org/atmos/camo.svg?branch=master)](https://travis-ci.org/atmos/camo) 2 | 3 | Camo is all about making insecure assets look secure. This is an SSL image proxy to prevent mixed content warnings on secure pages served from [GitHub](https://github.com). 4 | 5 | ![camo](https://cloud.githubusercontent.com/assets/38/24514552/88f29edc-1529-11e7-832f-6d2942144c87.gif) 6 | 7 | We want to allow people to keep embedding images in comments/issues/READMEs. 8 | 9 | [There's more info on the GitHub blog](https://github.com/blog/743-sidejack-prevention-phase-3-ssl-proxied-assets). 10 | 11 | Using a shared key, proxy URLs are authenticated with [hmac](http://en.wikipedia.org/wiki/HMAC) so we can bust caches/ban/rate limit if needed. 12 | 13 | Camo currently runs on node version 0.10.29 at GitHub on [heroku](http://heroku.com). 14 | 15 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/atmos/camo) 16 | 17 | Features 18 | -------- 19 | 20 | * Max size for proxied images 21 | * Follow redirects to a certain depth 22 | * Restricts proxied images content-types to a whitelist 23 | * Forward images regardless of HTTP status code 24 | 25 | At GitHub we render markdown and replace all of the `src` attributes on the `img` tags with the appropriate URL to hit the proxies. There's example code for creating URLs in [the tests](https://github.com/atmos/camo/blob/master/test/proxy_test.rb). 26 | 27 | ## URL Formats 28 | 29 | Camo supports two distinct URL formats: 30 | 31 | http://example.org/?url= 32 | http://example.org// 33 | 34 | The `` is a 40 character hex encoded HMAC digest generated with a shared 35 | secret key and the unescaped `` value. The `` is the 36 | absolute URL locating an image. In the first format, the `` should be 37 | URL escaped aggressively to ensure the original value isn't mangled in transit. 38 | In the second format, each byte of the `` should be hex encoded such 39 | that the resulting value includes only characters `[0-9a-f]`. 40 | 41 | ## Configuration 42 | 43 | Camo is configured through environment variables. 44 | 45 | * `PORT`: The port number Camo should listen on. (default: 8081) 46 | * `CAMO_HEADER_VIA`: The string for Camo to include in the `Via` and `User-Agent` headers it sends in requests to origin servers. (default: `Camo Asset Proxy `) 47 | * `CAMO_KEY`: A shared key consisting of a random string, used to generate the HMAC digest. 48 | * `CAMO_LENGTH_LIMIT`: The maximum `Content-Length` Camo will proxy. (default: 5242880) 49 | * `CAMO_LOGGING_ENABLED`: The logging level used for reporting debug or error information. Options are `debug` and `disabled`. (default: `disabled`) 50 | * `CAMO_MAX_REDIRECTS`: The maximum number of redirects Camo will follow while fetching an image. (default: 4) 51 | * `CAMO_SOCKET_TIMEOUT`: The maximum number of seconds Camo will wait before giving up on fetching an image. (default: 10) 52 | * `CAMO_TIMING_ALLOW_ORIGIN`: The string for Camo to include in the [`Timing-Allow-Origin` header](http://www.w3.org/TR/resource-timing/#cross-origin-resources) it sends in responses to clients. The header is omitted if this environment variable is not set. (default: not set) 53 | * `CAMO_HOSTNAME`: The `Camo-Host` header value that Camo will send. (default: `unknown`) 54 | * `CAMO_KEEP_ALIVE`: Whether or not to enable keep-alive session. (default: `false`) 55 | 56 | ## Testing Functionality 57 | 58 | ### Bundle Everything 59 | 60 | % rake bundle 61 | 62 | ### Start the server 63 | 64 | % coffee server.coffee 65 | 66 | ### In another shell 67 | 68 | % rake 69 | 70 | ### Debugging 71 | 72 | To see the full URL restclient is hitting etc, try this. 73 | 74 | % RESTCLIENT_LOG=stdout rake 75 | 76 | ### Deployment 77 | 78 | You should run this on heroku. 79 | 80 | To enable useful line numbers in stacktraces you probably want to compile the server.coffee file to native javascript when deploying. 81 | 82 | % coffee -c server.coffee 83 | % /usr/bin/env PORT=9090 CAMO_KEY="" node server.js 84 | 85 | ### Docker 86 | 87 | A `Dockerfile` is included, you can build and run it with: 88 | 89 | ```bash 90 | docker build -t camo . 91 | docker run --env CAMO_KEY=YOUR_KEY -t camo 92 | ``` 93 | 94 | ## Examples 95 | * Ruby - https://github.com/ankane/camo 96 | * PHP - https://github.com/willwashburn/Phpamo 97 | -------------------------------------------------------------------------------- /test/proxy_test.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'json' 3 | require 'base64' 4 | require 'openssl' 5 | require 'rest_client' 6 | require 'addressable/uri' 7 | 8 | require 'test/unit' 9 | 10 | module CamoProxyTests 11 | def config 12 | { 'key' => ENV['CAMO_KEY'] || "0x24FEEDFACEDEADBEEFCAFE", 13 | 'host' => ENV['CAMO_HOST'] || "http://localhost:8081" } 14 | end 15 | 16 | def spawn_server(path) 17 | port = 9292 18 | config = "test/servers/#{path}.ru" 19 | host = "localhost:#{port}" 20 | pid = fork do 21 | STDOUT.reopen "/dev/null" 22 | STDERR.reopen "/dev/null" 23 | exec "rackup", "--port", port.to_s, config 24 | end 25 | sleep 2 26 | begin 27 | yield host 28 | ensure 29 | Process.kill(:TERM, pid) 30 | Process.wait(pid) 31 | end 32 | end 33 | 34 | def test_proxy_localhost_test_server 35 | spawn_server(:ok) do |host| 36 | response = RestClient.get("http://#{host}/octocat.jpg") 37 | assert_equal(200, response.code) 38 | 39 | response = request("http://#{host}/octocat.jpg") 40 | assert_equal(200, response.code) 41 | end 42 | end 43 | 44 | def test_proxy_survives_redirect_without_location 45 | spawn_server(:redirect_without_location) do |host| 46 | assert_raise RestClient::ResourceNotFound do 47 | request("http://#{host}") 48 | end 49 | end 50 | 51 | response = request('http://media.ebaumsworld.com/picture/Mincemeat/Pimp.jpg') 52 | assert_equal(200, response.code) 53 | end 54 | 55 | def test_follows_https_redirect_for_image_links 56 | response = request('http://dl.dropbox.com/u/602885/github/soldier-squirrel.jpg') 57 | assert_equal(200, response.code) 58 | end 59 | 60 | def test_doesnt_crash_with_non_url_encoded_url 61 | assert_raise RestClient::ResourceNotFound do 62 | RestClient.get("#{config['host']}/crashme?url=crash&url=me") 63 | end 64 | end 65 | 66 | def test_always_sets_security_headers 67 | ['/', '/status'].each do |path| 68 | response = RestClient.get("#{config['host']}#{path}") 69 | assert_equal "deny", response.headers[:x_frame_options] 70 | assert_equal "default-src 'none'; img-src data:; style-src 'unsafe-inline'", response.headers[:content_security_policy] 71 | assert_equal "nosniff", response.headers[:x_content_type_options] 72 | assert_equal "max-age=31536000; includeSubDomains", response.headers[:strict_transport_security] 73 | end 74 | 75 | response = request('http://dl.dropbox.com/u/602885/github/soldier-squirrel.jpg') 76 | assert_equal "deny", response.headers[:x_frame_options] 77 | assert_equal "default-src 'none'; img-src data:; style-src 'unsafe-inline'", response.headers[:content_security_policy] 78 | assert_equal "nosniff", response.headers[:x_content_type_options] 79 | assert_equal "max-age=31536000; includeSubDomains", response.headers[:strict_transport_security] 80 | end 81 | 82 | def test_proxy_valid_image_url 83 | response = request('http://media.ebaumsworld.com/picture/Mincemeat/Pimp.jpg') 84 | assert_equal(200, response.code) 85 | end 86 | 87 | def test_svg_image_with_delimited_content_type_url 88 | response = request('https://saucelabs.com/browser-matrix/bootstrap.svg') 89 | assert_equal(200, response.code) 90 | end 91 | 92 | def test_proxy_valid_image_url_with_crazy_subdomain 93 | response = request('http://68.media.tumblr.com/c5834ed541c6f7dd760006b05754d4cf/tumblr_osr3veEPRj1uzkitwo1_1280.jpg') 94 | assert_equal(200, response.code) 95 | end 96 | 97 | def test_strict_image_content_type_checking 98 | assert_raise RestClient::ResourceNotFound do 99 | request("http://calm-shore-1799.herokuapp.com/foo.png") 100 | end 101 | end 102 | 103 | def test_proxy_valid_google_chart_url 104 | response = request('http://chart.apis.google.com/chart?chs=920x200&chxl=0:%7C2010-08-13%7C2010-09-12%7C2010-10-12%7C2010-11-11%7C1:%7C0%7C0%7C0%7C0%7C0%7C0&chm=B,EBF5FB,0,0,0&chco=008Cd6&chls=3,1,0&chg=8.3,20,1,4&chd=s:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&chxt=x,y&cht=lc') 105 | assert_equal(200, response.code) 106 | end 107 | 108 | def test_proxy_valid_chunked_image_file 109 | response = request('https://www.httpwatch.com/httpgallery/chunked/chunkedimage.aspx') 110 | assert_equal(200, response.code) 111 | assert_nil(response.headers[:content_length]) 112 | end 113 | 114 | def test_proxy_https_octocat 115 | response = request('https://octodex.github.com/images/original.png') 116 | assert_equal(200, response.code) 117 | end 118 | 119 | def test_proxy_https_gravatar 120 | response = request('https://1.gravatar.com/avatar/a86224d72ce21cd9f5bee6784d4b06c7') 121 | assert_equal(200, response.code) 122 | end 123 | 124 | def test_follows_redirects 125 | response = request('https://httpbin.org/redirect-to?status_code=301&url=https%3A%2F%2Fhttpbin.org%2Fimage%2Fjpeg') 126 | assert_equal(200, response.code) 127 | end 128 | 129 | def test_follows_redirects_with_path_only_location_headers 130 | assert_nothing_raised do 131 | request('https://httpbin.org/redirect-to?url=%2Fimage%2Fjpeg') 132 | end 133 | end 134 | 135 | def test_forwards_404_with_image 136 | spawn_server(:not_found) do |host| 137 | uri = request_uri("http://#{host}/octocat.jpg") 138 | response = RestClient.get(uri){ |response, request, result| response } 139 | assert_equal(404, response.code) 140 | assert_equal("image/jpeg", response.headers[:content_type]) 141 | end 142 | end 143 | 144 | def test_404s_on_request_error 145 | spawn_server(:crash_request) do |host| 146 | assert_raise RestClient::ResourceNotFound do 147 | request("http://#{host}/cats.png") 148 | end 149 | end 150 | end 151 | 152 | def test_404s_on_infinidirect 153 | assert_raise RestClient::ResourceNotFound do 154 | request('http://modeselektor.herokuapp.com/') 155 | end 156 | end 157 | 158 | def test_404s_on_urls_without_an_http_host 159 | assert_raise RestClient::ResourceNotFound do 160 | request('/picture/Mincemeat/Pimp.jpg') 161 | end 162 | end 163 | 164 | def test_404s_on_images_greater_than_5_megabytes 165 | assert_raise RestClient::ResourceNotFound do 166 | request('http://apod.nasa.gov/apod/image/0505/larryslookout_spirit_big.jpg') 167 | end 168 | end 169 | 170 | def test_404s_on_host_not_found 171 | assert_raise RestClient::ResourceNotFound do 172 | request('http://flabergasted.cx') 173 | end 174 | end 175 | 176 | def test_404s_on_non_image_content_type 177 | assert_raise RestClient::ResourceNotFound do 178 | request('https://github.com/atmos/cinderella/raw/master/bootstrap.sh') 179 | end 180 | end 181 | 182 | def test_404s_on_connect_timeout 183 | assert_raise RestClient::ResourceNotFound do 184 | request('http://10.0.0.1/foo.cgi') 185 | end 186 | end 187 | 188 | def test_404s_on_environmental_excludes 189 | assert_raise RestClient::ResourceNotFound do 190 | request('http://iphone.internal.example.org/foo.cgi') 191 | end 192 | end 193 | 194 | def test_follows_temporary_redirects 195 | response = request('https://httpbin.org/redirect-to?status_code=302&url=https%3A%2F%2Fhttpbin.org%2Fimage%2Fjpeg') 196 | assert_equal(200, response.code) 197 | end 198 | 199 | def test_request_from_self 200 | assert_raise RestClient::ResourceNotFound do 201 | uri = request_uri("http://camo-localhost-test.herokuapp.com") 202 | response = request( uri ) 203 | end 204 | end 205 | 206 | def test_404s_send_cache_headers 207 | uri = request_uri("http://example.org/") 208 | response = RestClient.get(uri){ |response, request, result| response } 209 | assert_equal(404, response.code) 210 | assert_equal("0", response.headers[:expires]) 211 | assert_equal("no-cache, no-store, private, must-revalidate", response.headers[:cache_control]) 212 | end 213 | end 214 | 215 | class CamoProxyQueryStringTest < Test::Unit::TestCase 216 | include CamoProxyTests 217 | 218 | def request_uri(image_url) 219 | hexdigest = OpenSSL::HMAC.hexdigest( 220 | OpenSSL::Digest.new('sha1'), config['key'], image_url) 221 | 222 | uri = Addressable::URI.parse("#{config['host']}/#{hexdigest}") 223 | uri.query_values = { 'url' => image_url, 'repo' => '', 'path' => '' } 224 | 225 | uri.to_s 226 | end 227 | 228 | def request(image_url) 229 | RestClient.get(request_uri(image_url)) 230 | end 231 | end 232 | 233 | class CamoProxyPathTest < Test::Unit::TestCase 234 | include CamoProxyTests 235 | 236 | def hexenc(image_url) 237 | image_url.to_enum(:each_byte).map { |byte| "%02x" % byte }.join 238 | end 239 | 240 | def request_uri(image_url) 241 | hexdigest = OpenSSL::HMAC.hexdigest( 242 | OpenSSL::Digest.new('sha1'), config['key'], image_url) 243 | encoded_image_url = hexenc(image_url) 244 | "#{config['host']}/#{hexdigest}/#{encoded_image_url}" 245 | end 246 | 247 | def request(image_url) 248 | RestClient.get(request_uri(image_url)) 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /server.coffee: -------------------------------------------------------------------------------- 1 | Fs = require 'fs' 2 | Path = require 'path' 3 | Url = require 'url' 4 | Http = require 'http' 5 | Https = require 'https' 6 | Crypto = require 'crypto' 7 | QueryString = require 'querystring' 8 | 9 | port = parseInt process.env.PORT || 8081, 10 10 | version = require(Path.resolve(__dirname, "package.json")).version 11 | shared_key = process.env.CAMO_KEY || '0x24FEEDFACEDEADBEEFCAFE' 12 | max_redirects = process.env.CAMO_MAX_REDIRECTS || 4 13 | camo_hostname = process.env.CAMO_HOSTNAME || "unknown" 14 | socket_timeout = process.env.CAMO_SOCKET_TIMEOUT || 10 15 | logging_enabled = process.env.CAMO_LOGGING_ENABLED || "disabled" 16 | keep_alive = process.env.CAMO_KEEP_ALIVE || "false" 17 | 18 | content_length_limit = parseInt(process.env.CAMO_LENGTH_LIMIT || 5242880, 10) 19 | 20 | accepted_image_mime_types = JSON.parse(Fs.readFileSync( 21 | Path.resolve(__dirname, "mime-types.json"), 22 | encoding: 'utf8' 23 | )) 24 | 25 | debug_log = (msg) -> 26 | if logging_enabled == "debug" 27 | console.log("--------------------------------------------") 28 | console.log(msg) 29 | console.log("--------------------------------------------") 30 | 31 | error_log = (msg) -> 32 | unless logging_enabled == "disabled" 33 | console.error("[#{new Date().toISOString()}] #{msg}") 34 | 35 | total_connections = 0 36 | current_connections = 0 37 | started_at = new Date 38 | 39 | default_security_headers = 40 | "X-Frame-Options": "deny" 41 | "X-XSS-Protection": "1; mode=block" 42 | "X-Content-Type-Options": "nosniff" 43 | "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'" 44 | "Strict-Transport-Security" : "max-age=31536000; includeSubDomains" 45 | 46 | four_oh_four = (resp, msg, url) -> 47 | error_log "#{msg}: #{url?.format() or 'unknown'}" 48 | resp.writeHead 404, 49 | expires: "0" 50 | "Cache-Control": "no-cache, no-store, private, must-revalidate" 51 | "X-Frame-Options" : default_security_headers["X-Frame-Options"] 52 | "X-XSS-Protection" : default_security_headers["X-XSS-Protection"] 53 | "X-Content-Type-Options" : default_security_headers["X-Content-Type-Options"] 54 | "Content-Security-Policy" : default_security_headers["Content-Security-Policy"] 55 | "Strict-Transport-Security" : default_security_headers["Strict-Transport-Security"] 56 | 57 | finish resp, "Not Found" 58 | 59 | finish = (resp, str) -> 60 | current_connections -= 1 61 | current_connections = 0 if current_connections < 1 62 | resp.connection && resp.end str 63 | 64 | process_url = (url, transferredHeaders, resp, remaining_redirects) -> 65 | if url.host? 66 | if url.protocol is 'https:' 67 | Protocol = Https 68 | else if url.protocol is 'http:' 69 | Protocol = Http 70 | else 71 | four_oh_four(resp, "Unknown protocol", url) 72 | return 73 | 74 | queryPath = url.pathname 75 | if url.query? 76 | queryPath += "?#{url.query}" 77 | 78 | transferredHeaders.host = url.host 79 | debug_log transferredHeaders 80 | 81 | requestOptions = 82 | hostname: url.hostname 83 | port: url.port 84 | path: queryPath 85 | headers: transferredHeaders 86 | 87 | if keep_alive == "false" 88 | requestOptions['agent'] = false 89 | 90 | srcReq = Protocol.get requestOptions, (srcResp) -> 91 | is_finished = true 92 | 93 | debug_log srcResp.headers 94 | 95 | content_length = srcResp.headers['content-length'] 96 | 97 | if content_length > content_length_limit 98 | srcResp.destroy() 99 | four_oh_four(resp, "Content-Length exceeded", url) 100 | else 101 | newHeaders = 102 | 'content-type' : srcResp.headers['content-type'] 103 | 'cache-control' : srcResp.headers['cache-control'] || 'public, max-age=31536000' 104 | 'Camo-Host' : camo_hostname 105 | 'X-Frame-Options' : default_security_headers['X-Frame-Options'] 106 | 'X-XSS-Protection' : default_security_headers['X-XSS-Protection'] 107 | 'X-Content-Type-Options' : default_security_headers['X-Content-Type-Options'] 108 | 'Content-Security-Policy' : default_security_headers['Content-Security-Policy'] 109 | 'Strict-Transport-Security' : default_security_headers['Strict-Transport-Security'] 110 | 111 | if eTag = srcResp.headers['etag'] 112 | newHeaders['etag'] = eTag 113 | 114 | if expiresHeader = srcResp.headers['expires'] 115 | newHeaders['expires'] = expiresHeader 116 | 117 | if lastModified = srcResp.headers['last-modified'] 118 | newHeaders['last-modified'] = lastModified 119 | 120 | if origin = process.env.CAMO_TIMING_ALLOW_ORIGIN 121 | newHeaders['Timing-Allow-Origin'] = origin 122 | 123 | # Handle chunked responses properly 124 | if content_length? 125 | newHeaders['content-length'] = content_length 126 | if srcResp.headers['transfer-encoding'] 127 | newHeaders['transfer-encoding'] = srcResp.headers['transfer-encoding'] 128 | if srcResp.headers['content-encoding'] 129 | newHeaders['content-encoding'] = srcResp.headers['content-encoding'] 130 | 131 | srcResp.on 'end', -> 132 | if is_finished 133 | finish resp 134 | srcResp.on 'error', -> 135 | if is_finished 136 | finish resp 137 | 138 | switch srcResp.statusCode 139 | when 301, 302, 303, 307 140 | srcResp.destroy() 141 | if remaining_redirects <= 0 142 | four_oh_four(resp, "Exceeded max depth", url) 143 | else if !srcResp.headers['location'] 144 | four_oh_four(resp, "Redirect with no location", url) 145 | else 146 | is_finished = false 147 | newUrl = Url.parse srcResp.headers['location'] 148 | unless newUrl.host? and newUrl.hostname? 149 | newUrl.host = newUrl.hostname = url.hostname 150 | newUrl.protocol = url.protocol 151 | 152 | debug_log "Redirected to #{newUrl.format()}" 153 | process_url newUrl, transferredHeaders, resp, remaining_redirects - 1 154 | when 304 155 | srcResp.destroy() 156 | resp.writeHead srcResp.statusCode, newHeaders 157 | else 158 | contentType = newHeaders['content-type'] 159 | 160 | unless contentType? 161 | srcResp.destroy() 162 | four_oh_four(resp, "No content-type returned", url) 163 | return 164 | 165 | contentTypePrefix = contentType.split(";")[0].toLowerCase() 166 | 167 | unless contentTypePrefix in accepted_image_mime_types 168 | srcResp.destroy() 169 | four_oh_four(resp, "Non-Image content-type returned '#{contentTypePrefix}'", url) 170 | return 171 | 172 | debug_log newHeaders 173 | 174 | resp.writeHead srcResp.statusCode, newHeaders 175 | srcResp.pipe resp 176 | 177 | srcReq.setTimeout (socket_timeout * 1000), -> 178 | srcReq.abort() 179 | four_oh_four resp, "Socket timeout", url 180 | 181 | srcReq.on 'error', (error) -> 182 | four_oh_four(resp, "Client Request error #{error.stack}", url) 183 | 184 | resp.on 'close', -> 185 | error_log("Request aborted") 186 | srcReq.abort() 187 | 188 | resp.on 'error', (e) -> 189 | error_log("Request error: #{e}") 190 | srcReq.abort() 191 | else 192 | four_oh_four(resp, "No host found " + url.host, url) 193 | 194 | # decode a string of two char hex digits 195 | hexdec = (str) -> 196 | if str and str.length > 0 and str.length % 2 == 0 and not str.match(/[^0-9a-f]/) 197 | buf = new Buffer(str.length / 2) 198 | for i in [0...str.length] by 2 199 | buf[i/2] = parseInt(str[i..i+1], 16) 200 | buf.toString() 201 | 202 | server = Http.createServer (req, resp) -> 203 | if req.method != 'GET' || req.url == '/' 204 | resp.writeHead 200, default_security_headers 205 | resp.end 'hwhat' 206 | else if req.url == '/favicon.ico' 207 | resp.writeHead 200, default_security_headers 208 | resp.end 'ok' 209 | else if req.url == '/status' 210 | resp.writeHead 200, default_security_headers 211 | resp.end "ok #{current_connections}/#{total_connections} since #{started_at.toString()}" 212 | else 213 | total_connections += 1 214 | current_connections += 1 215 | url = Url.parse req.url 216 | user_agent = process.env.CAMO_HEADER_VIA or= "Camo Asset Proxy #{version}" 217 | 218 | transferredHeaders = 219 | 'Via' : req.headers['user-agent'] ? user_agent 220 | 'User-Agent' : req.headers['user-agent'] ? user_agent 221 | 'Accept' : req.headers.accept ? 'image/*' 222 | 'Accept-Encoding' : req.headers['accept-encoding'] ? '' 223 | "X-Frame-Options" : default_security_headers["X-Frame-Options"] 224 | "X-XSS-Protection" : default_security_headers["X-XSS-Protection"] 225 | "X-Content-Type-Options" : default_security_headers["X-Content-Type-Options"] 226 | "Content-Security-Policy" : default_security_headers["Content-Security-Policy"] 227 | 228 | delete(req.headers.cookie) 229 | 230 | [query_digest, encoded_url] = url.pathname.replace(/^\//, '').split("/", 2) 231 | if encoded_url = hexdec(encoded_url) 232 | url_type = 'path' 233 | dest_url = encoded_url 234 | else 235 | url_type = 'query' 236 | dest_url = QueryString.parse(url.query).url 237 | 238 | debug_log({ 239 | type: url_type 240 | url: req.url 241 | headers: req.headers 242 | dest: dest_url 243 | digest: query_digest 244 | }) 245 | 246 | if req.headers['via'] && req.headers['via'].indexOf(user_agent) != -1 247 | return four_oh_four(resp, "Requesting from self") 248 | 249 | if url.pathname? && dest_url 250 | hmac = Crypto.createHmac("sha1", shared_key) 251 | 252 | try 253 | hmac.update(dest_url, 'utf8') 254 | catch error 255 | return four_oh_four(resp, "could not create checksum") 256 | 257 | hmac_digest = hmac.digest('hex') 258 | 259 | if hmac_digest == query_digest 260 | url = Url.parse dest_url 261 | 262 | process_url url, transferredHeaders, resp, max_redirects 263 | else 264 | four_oh_four(resp, "checksum mismatch #{hmac_digest}:#{query_digest}") 265 | else 266 | four_oh_four(resp, "No pathname provided on the server") 267 | 268 | console.log "SSL-Proxy running on #{port} with node:#{process.version} pid:#{process.pid} version:#{version}." 269 | 270 | server.listen port -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 2.3.2 2 | (function() { 3 | var Crypto, Fs, Http, Https, Path, QueryString, Url, accepted_image_mime_types, camo_hostname, content_length_limit, current_connections, debug_log, default_security_headers, error_log, finish, four_oh_four, hexdec, keep_alive, logging_enabled, max_redirects, port, process_url, server, shared_key, socket_timeout, started_at, total_connections, version, 4 | indexOf = [].indexOf; 5 | 6 | Fs = require('fs'); 7 | 8 | Path = require('path'); 9 | 10 | Url = require('url'); 11 | 12 | Http = require('http'); 13 | 14 | Https = require('https'); 15 | 16 | Crypto = require('crypto'); 17 | 18 | QueryString = require('querystring'); 19 | 20 | port = parseInt(process.env.PORT || 8081, 10); 21 | 22 | version = require(Path.resolve(__dirname, "package.json")).version; 23 | 24 | shared_key = process.env.CAMO_KEY || '0x24FEEDFACEDEADBEEFCAFE'; 25 | 26 | max_redirects = process.env.CAMO_MAX_REDIRECTS || 4; 27 | 28 | camo_hostname = process.env.CAMO_HOSTNAME || "unknown"; 29 | 30 | socket_timeout = process.env.CAMO_SOCKET_TIMEOUT || 10; 31 | 32 | logging_enabled = process.env.CAMO_LOGGING_ENABLED || "disabled"; 33 | 34 | keep_alive = process.env.CAMO_KEEP_ALIVE || "false"; 35 | 36 | content_length_limit = parseInt(process.env.CAMO_LENGTH_LIMIT || 5242880, 10); 37 | 38 | accepted_image_mime_types = JSON.parse(Fs.readFileSync(Path.resolve(__dirname, "mime-types.json"), { 39 | encoding: 'utf8' 40 | })); 41 | 42 | debug_log = function(msg) { 43 | if (logging_enabled === "debug") { 44 | console.log("--------------------------------------------"); 45 | console.log(msg); 46 | return console.log("--------------------------------------------"); 47 | } 48 | }; 49 | 50 | error_log = function(msg) { 51 | if (logging_enabled !== "disabled") { 52 | return console.error(`[${new Date().toISOString()}] ${msg}`); 53 | } 54 | }; 55 | 56 | total_connections = 0; 57 | 58 | current_connections = 0; 59 | 60 | started_at = new Date; 61 | 62 | default_security_headers = { 63 | "X-Frame-Options": "deny", 64 | "X-XSS-Protection": "1; mode=block", 65 | "X-Content-Type-Options": "nosniff", 66 | "Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'", 67 | "Strict-Transport-Security": "max-age=31536000; includeSubDomains" 68 | }; 69 | 70 | four_oh_four = function(resp, msg, url) { 71 | error_log(`${msg}: ${(url != null ? url.format() : void 0) || 'unknown'}`); 72 | resp.writeHead(404, { 73 | expires: "0", 74 | "Cache-Control": "no-cache, no-store, private, must-revalidate", 75 | "X-Frame-Options": default_security_headers["X-Frame-Options"], 76 | "X-XSS-Protection": default_security_headers["X-XSS-Protection"], 77 | "X-Content-Type-Options": default_security_headers["X-Content-Type-Options"], 78 | "Content-Security-Policy": default_security_headers["Content-Security-Policy"], 79 | "Strict-Transport-Security": default_security_headers["Strict-Transport-Security"] 80 | }); 81 | return finish(resp, "Not Found"); 82 | }; 83 | 84 | finish = function(resp, str) { 85 | current_connections -= 1; 86 | if (current_connections < 1) { 87 | current_connections = 0; 88 | } 89 | return resp.connection && resp.end(str); 90 | }; 91 | 92 | process_url = function(url, transferredHeaders, resp, remaining_redirects) { 93 | var Protocol, queryPath, requestOptions, srcReq; 94 | if (url.host != null) { 95 | if (url.protocol === 'https:') { 96 | Protocol = Https; 97 | } else if (url.protocol === 'http:') { 98 | Protocol = Http; 99 | } else { 100 | four_oh_four(resp, "Unknown protocol", url); 101 | return; 102 | } 103 | queryPath = url.pathname; 104 | if (url.query != null) { 105 | queryPath += `?${url.query}`; 106 | } 107 | transferredHeaders.host = url.host; 108 | debug_log(transferredHeaders); 109 | requestOptions = { 110 | hostname: url.hostname, 111 | port: url.port, 112 | path: queryPath, 113 | headers: transferredHeaders 114 | }; 115 | if (keep_alive === "false") { 116 | requestOptions['agent'] = false; 117 | } 118 | srcReq = Protocol.get(requestOptions, function(srcResp) { 119 | var contentType, contentTypePrefix, content_length, eTag, expiresHeader, is_finished, lastModified, newHeaders, newUrl, origin; 120 | is_finished = true; 121 | debug_log(srcResp.headers); 122 | content_length = srcResp.headers['content-length']; 123 | if (content_length > content_length_limit) { 124 | srcResp.destroy(); 125 | return four_oh_four(resp, "Content-Length exceeded", url); 126 | } else { 127 | newHeaders = { 128 | 'content-type': srcResp.headers['content-type'], 129 | 'cache-control': srcResp.headers['cache-control'] || 'public, max-age=31536000', 130 | 'Camo-Host': camo_hostname, 131 | 'X-Frame-Options': default_security_headers['X-Frame-Options'], 132 | 'X-XSS-Protection': default_security_headers['X-XSS-Protection'], 133 | 'X-Content-Type-Options': default_security_headers['X-Content-Type-Options'], 134 | 'Content-Security-Policy': default_security_headers['Content-Security-Policy'], 135 | 'Strict-Transport-Security': default_security_headers['Strict-Transport-Security'] 136 | }; 137 | if (eTag = srcResp.headers['etag']) { 138 | newHeaders['etag'] = eTag; 139 | } 140 | if (expiresHeader = srcResp.headers['expires']) { 141 | newHeaders['expires'] = expiresHeader; 142 | } 143 | if (lastModified = srcResp.headers['last-modified']) { 144 | newHeaders['last-modified'] = lastModified; 145 | } 146 | if (origin = process.env.CAMO_TIMING_ALLOW_ORIGIN) { 147 | newHeaders['Timing-Allow-Origin'] = origin; 148 | } 149 | // Handle chunked responses properly 150 | if (content_length != null) { 151 | newHeaders['content-length'] = content_length; 152 | } 153 | if (srcResp.headers['transfer-encoding']) { 154 | newHeaders['transfer-encoding'] = srcResp.headers['transfer-encoding']; 155 | } 156 | if (srcResp.headers['content-encoding']) { 157 | newHeaders['content-encoding'] = srcResp.headers['content-encoding']; 158 | } 159 | srcResp.on('end', function() { 160 | if (is_finished) { 161 | return finish(resp); 162 | } 163 | }); 164 | srcResp.on('error', function() { 165 | if (is_finished) { 166 | return finish(resp); 167 | } 168 | }); 169 | switch (srcResp.statusCode) { 170 | case 301: 171 | case 302: 172 | case 303: 173 | case 307: 174 | srcResp.destroy(); 175 | if (remaining_redirects <= 0) { 176 | return four_oh_four(resp, "Exceeded max depth", url); 177 | } else if (!srcResp.headers['location']) { 178 | return four_oh_four(resp, "Redirect with no location", url); 179 | } else { 180 | is_finished = false; 181 | newUrl = Url.parse(srcResp.headers['location']); 182 | if (!((newUrl.host != null) && (newUrl.hostname != null))) { 183 | newUrl.host = newUrl.hostname = url.hostname; 184 | newUrl.protocol = url.protocol; 185 | } 186 | debug_log(`Redirected to ${newUrl.format()}`); 187 | return process_url(newUrl, transferredHeaders, resp, remaining_redirects - 1); 188 | } 189 | break; 190 | case 304: 191 | srcResp.destroy(); 192 | return resp.writeHead(srcResp.statusCode, newHeaders); 193 | default: 194 | contentType = newHeaders['content-type']; 195 | if (contentType == null) { 196 | srcResp.destroy(); 197 | four_oh_four(resp, "No content-type returned", url); 198 | return; 199 | } 200 | contentTypePrefix = contentType.split(";")[0].toLowerCase(); 201 | if (indexOf.call(accepted_image_mime_types, contentTypePrefix) < 0) { 202 | srcResp.destroy(); 203 | four_oh_four(resp, `Non-Image content-type returned '${contentTypePrefix}'`, url); 204 | return; 205 | } 206 | debug_log(newHeaders); 207 | resp.writeHead(srcResp.statusCode, newHeaders); 208 | return srcResp.pipe(resp); 209 | } 210 | } 211 | }); 212 | srcReq.setTimeout(socket_timeout * 1000, function() { 213 | srcReq.abort(); 214 | return four_oh_four(resp, "Socket timeout", url); 215 | }); 216 | srcReq.on('error', function(error) { 217 | return four_oh_four(resp, `Client Request error ${error.stack}`, url); 218 | }); 219 | resp.on('close', function() { 220 | error_log("Request aborted"); 221 | return srcReq.abort(); 222 | }); 223 | return resp.on('error', function(e) { 224 | error_log(`Request error: ${e}`); 225 | return srcReq.abort(); 226 | }); 227 | } else { 228 | return four_oh_four(resp, "No host found " + url.host, url); 229 | } 230 | }; 231 | 232 | // decode a string of two char hex digits 233 | hexdec = function(str) { 234 | var buf, i, j, ref; 235 | if (str && str.length > 0 && str.length % 2 === 0 && !str.match(/[^0-9a-f]/)) { 236 | buf = new Buffer(str.length / 2); 237 | for (i = j = 0, ref = str.length; j < ref; i = j += 2) { 238 | buf[i / 2] = parseInt(str.slice(i, +(i + 1) + 1 || 9e9), 16); 239 | } 240 | return buf.toString(); 241 | } 242 | }; 243 | 244 | server = Http.createServer(function(req, resp) { 245 | var base, dest_url, encoded_url, error, hmac, hmac_digest, query_digest, ref, ref1, ref2, ref3, transferredHeaders, url, url_type, user_agent; 246 | if (req.method !== 'GET' || req.url === '/') { 247 | resp.writeHead(200, default_security_headers); 248 | return resp.end('hwhat'); 249 | } else if (req.url === '/favicon.ico') { 250 | resp.writeHead(200, default_security_headers); 251 | return resp.end('ok'); 252 | } else if (req.url === '/status') { 253 | resp.writeHead(200, default_security_headers); 254 | return resp.end(`ok ${current_connections}/${total_connections} since ${started_at.toString()}`); 255 | } else { 256 | total_connections += 1; 257 | current_connections += 1; 258 | url = Url.parse(req.url); 259 | user_agent = (base = process.env).CAMO_HEADER_VIA || (base.CAMO_HEADER_VIA = `Camo Asset Proxy ${version}`); 260 | transferredHeaders = { 261 | 'Via': (ref = req.headers['user-agent']) != null ? ref : user_agent, 262 | 'User-Agent': (ref1 = req.headers['user-agent']) != null ? ref1 : user_agent, 263 | 'Accept': (ref2 = req.headers.accept) != null ? ref2 : 'image/*', 264 | 'Accept-Encoding': (ref3 = req.headers['accept-encoding']) != null ? ref3 : '', 265 | "X-Frame-Options": default_security_headers["X-Frame-Options"], 266 | "X-XSS-Protection": default_security_headers["X-XSS-Protection"], 267 | "X-Content-Type-Options": default_security_headers["X-Content-Type-Options"], 268 | "Content-Security-Policy": default_security_headers["Content-Security-Policy"] 269 | }; 270 | delete req.headers.cookie; 271 | [query_digest, encoded_url] = url.pathname.replace(/^\//, '').split("/", 2); 272 | if (encoded_url = hexdec(encoded_url)) { 273 | url_type = 'path'; 274 | dest_url = encoded_url; 275 | } else { 276 | url_type = 'query'; 277 | dest_url = QueryString.parse(url.query).url; 278 | } 279 | debug_log({ 280 | type: url_type, 281 | url: req.url, 282 | headers: req.headers, 283 | dest: dest_url, 284 | digest: query_digest 285 | }); 286 | if (req.headers['via'] && req.headers['via'].indexOf(user_agent) !== -1) { 287 | return four_oh_four(resp, "Requesting from self"); 288 | } 289 | if ((url.pathname != null) && dest_url) { 290 | hmac = Crypto.createHmac("sha1", shared_key); 291 | try { 292 | hmac.update(dest_url, 'utf8'); 293 | } catch (error1) { 294 | error = error1; 295 | return four_oh_four(resp, "could not create checksum"); 296 | } 297 | hmac_digest = hmac.digest('hex'); 298 | if (hmac_digest === query_digest) { 299 | url = Url.parse(dest_url); 300 | return process_url(url, transferredHeaders, resp, max_redirects); 301 | } else { 302 | return four_oh_four(resp, `checksum mismatch ${hmac_digest}:${query_digest}`); 303 | } 304 | } else { 305 | return four_oh_four(resp, "No pathname provided on the server"); 306 | } 307 | } 308 | }); 309 | 310 | console.log(`SSL-Proxy running on ${port} with node:${process.version} pid:${process.pid} version:${version}.`); 311 | 312 | server.listen(port); 313 | 314 | }).call(this); 315 | --------------------------------------------------------------------------------