前言
这几天想做一个通过jupyter api实现的自制前端UI,而访问api的方式自然选择了使用ajax。本以为这会是一个很简答的任务,但是实际操作起来,发现利用ajax访问jupyter api时存在跨域的问题,由于jupyterhub运行后,开放在默认的8000端口上,而自制的页面一般在80/443端口,因此要自制这个页面需要先解决跨域的问题。
表现症状
在请求相关接口时,控制台中提示
Response to preflight request doesn’t pass access control check: No ‘Access-Control-Allow-Origin’ header is present on the requested resource.
简单的翻译一下就是 在被请求的资源上没有被允许的非同源origin
同时也可以在network中看到
方法为OPTIONS,表示在试探服务器是否能够相应这个api,而Response Headers返回给浏览器,告知服务端相关的情况,而这个时候在这个响应头中没有Access-Control-Allow-Origin一项,浏览器便默认不可进行跨域访问,即非同源的请求在浏览器一端就会被拦截。而如果响应头中有Access-Control-Allow-Origin=“*”,表示服务器可以接受来自任何源的请求,这个时候浏览器就不会自动拦截请求了。因此,针对jupyterhub应用场景(既要post又要get),在跨域解决方案上,我选择了CORS。
解决原理
CORS是一种可以让你实现跨站点请求并同时阻止恶意js的请求,它会在你发送下面几种HTTP请求时触发:
– 不同的域名 (比如在网站 example.com 请求 api.com)
– 不同的子域名 (比如在网站 example.com 请求 api.example.com)
– 不同的端口 (比如在网站 example.com 请求 example.com:3001)
– 不同协议 (比如在网站 https://example.com 请求 http://example.com)
这个机制阻止攻击者在一些网站上放置js脚本(比如通过Googls Ads展示的广告)发起一个AJAX请求访问www.yourbank.com,假设你刚好登陆过这个网站,就可能使用你的验证信息发起一笔转账。
如果你的浏览器发起一个“非简单”请求(比如这个请求里包含了cookies,或者Content-type是application/x-ww-form-urlencoded, multipart/form-data 或者 text-plain)一个叫做预检查的机制会发送一个OPTIONS请求到服务器。如果服务器没有返回带有特殊头部的数据,简单请求GET或者POST请求仍然会发送,服务器的数据也会返回,但是浏览器会阻止Javascript获取这次请求。
如果明确的需要在一个请求里添加cookies,自定义头部信息或则其他特性,这将不在是一个简单请求,并且服务器没有适当的返回,这次请求讲不会发送。就是复杂请求时,如果OPTIONS的请求,服务器没有做出适当的返回,后面真实的请求将不会发送。
解决方案
本方案针对jupyterhub,其他库大同小异
方案一(原创):
用命令找到jupyterhub/handlers/base.py,188行处set_default_headers函数增加三行内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def set_default_headers(self): """ Set any headers passed as tornado_settings['headers']. By default sets Content-Security-Policy of frame-ancestors 'self'. Also responsible for setting content-type header """ # wrap in HTTPHeaders for case-insensitivity headers = HTTPHeaders(self.settings.get('headers', {})) headers.setdefault("X-JupyterHub-Version", __version__) <strong>self.set_header('Access-Control-Allow-Origin', '*') self.set_header('Access-Control-Allow-Headers', 'x-requested-with') self.set_header('Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE')</strong> for header_name, header_content in headers.items(): self.set_header(header_name, header_content) if 'Access-Control-Allow-Headers' not in headers: self.set_header( 'Access-Control-Allow-Headers', 'accept, content-type, authorization' ) if 'Content-Security-Policy' not in headers: self.set_header('Content-Security-Policy', self.content_security_policy) self.set_header('Content-Type', self.get_content_type()) |
用命令找到jupyterhub/singleuser.py,593行附近
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
def init_webapp(self): # load the hub-related settings into the tornado settings dict self.init_hub_auth() s = self.tornado_settings s['log_function'] = log_request s['user'] = self.user s['group'] = self.group s['hub_prefix'] = self.hub_prefix s['hub_host'] = self.hub_host s['hub_auth'] = self.hub_auth csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join( self.hub_prefix, 'security/csp-report' ) headers = s.setdefault('headers', {}) headers['X-JupyterHub-Version'] = __version__ <strong>headers['Access-Control-Allow-Origin'] = '*'</strong> # set CSP header directly to workaround bugs in jupyter/notebook 5.0 headers.setdefault( 'Content-Security-Policy', ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]), ) super(SingleUserNotebookApp, self).init_webapp() # add OAuth callback self.web_app.add_handlers( r".*$", [(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)], ) # apply X-JupyterHub-Version to *all* request handlers (even redirects) self.patch_default_headers() self.patch_templates() |
用命令找到jupyter_notebook_config.py,48行附近
1 |
c.NotebookApp.allow_origin = '*' |
最后重启服务就可以看到效果了
灵感来源:base.py往往是被其他文件集成,用于配置一些基本的相应等形式,因此对于跨域问题,可以优先去找base.py,并修改其内容,但是当修改完成之后,用下面的测试程序测试发现第一个/hub/api通过了,但是第二个/user/xukeqin/api则失败了,从请求的反馈来看,这两者api存在一个典型的区别是,前者是全局的api,而后者是针对用户的api,因此其配置文件可能另存在其他文件中,通过spawner创建进程的形式产生该api接口,所以我在这个库文件中搜索相应的关键字,渴望找到该单用户进程的人口,最终经过筛选,锁定在singeruser.py中
方案2
用命令找到jupyterhub_config.py,增加以下内容
1 2 3 4 5 6 7 8 9 10 |
# the origin from which you would like to allow cross-origin requests origin = 'http://localhost:9999' c.Spawner.args = [f'--NotebookApp.allow_origin={origin}'] c.JupyterHub.tornado_settings = { 'headers': { 'Access-Control-Allow-Origin': origin, }, } |
juoyterhub的配置和jupyter notebook的不太一样,后者修改完配置文件后,能够直接生效,但是jupyterhub经过测试,需要添加命令行参数才可以手动生效,命令如下:
1 |
jupyterhub -f /etc/jupyterhub/jupyterhub_config.py |
最终测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
<html> <head> <title>JupyterHub CORS test</title> <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"> </script> <style type="text/css"> input[type=text] { width: 50ex; } </style> </head> <body> <p> To run this test: <ol> <li>Get your API token from the JupyterHub token page and paste the value in the field below.</li> <li>Start your notebook with the Hub and paste the URL for your server in the field below.</li> <li>Click the test button to make an AJAX request to the server</li> </ol> If CORS is enabled, this request will succeed and show the notebook status. Otherwise, it will be rejected. </p> <label for="url">JupyterHub URL:</label> <input type="text" id="url" placeholder="jupyterhub URL" value="http://127.0.0.1:8000"/> <br> <label for="token">JupyterHub token:</label> <input type="text" id="token" placeholder="paste your token here"/> <br> <button id="test-button">test</button> <br> <div>Username: <span id="username"></span></div> <div>Status: <span id="status"></span></div> <div> output: <br> <pre id="output"></pre> </div> <script type="text/javascript"> function fail(err) { $("#output").text("Failed! Check the console."); console.error(err); } function test() { var token = $("#token").val().trim(); var hub_url = $("#url").val().trim(); if ( ! /\/$/.exec(hub_url) ) { // ensure trailing slash hub_url = hub_url + '/'; } var hub_api = hub_url + 'hub/api/'; var headers = { Authorization: "token " + token }; // check the user $.get({ url: hub_api + 'user', headers: headers, }).then(function (user) { console.log(user); $("#username").text(user.name); if (!user.server) { // if not running, start the server and fetch user info again $("#status").text("starting..."); console.log("starting") return $.post({ url: hub_api + 'users/' + user.name + '/server', headers: headers, }).then(function () { $("#status").text("started"); return $.get({ url: hub_api + 'user', headers: headers, }); }); } else { return user; } }).catch(fail) .then(function (user) { console.log('running', user) $("#status").text("running"); var user_url = hub_url + user.server.replace(/^\//, ''); return $.get({ url: user_url + 'api/status', headers: headers, }).then(function (reply) { $("#status").text("done"); $("#output").text(JSON.stringify(reply, null, 2)); }).catch(fail); }); } $('#test-button').click(test); test() </script> </body> </html> |
如果最后没有任何提示请求失败,就表示跨域成功咯!
解决iframe安全问题
在jupyterhub_config.py中设置
1 2 3 |
c.Spawner.args = ['--NotebookApp.tornado_settings={"headers":{"Content-Security-Policy": "frame-ancestors * self host_ip:port"}}'] c.JupyterHub.tornado_settings = { 'headers': { 'Content-Security-Policy': "frame-ancestors * self host_ip:port"} } |
其中host_ip为主机号,port为端口号