@@ -7,14 +7,39 @@ const { drive } = require("googleapis/build/src/apis/drive/index.js");
77
88var BACKREF_KEY = "originalProgram" ;
99
10+ // Limits for the streaming proxy. /downloadImg gets larger/looser caps because
11+ // images can legitimately be tens of MB; also we've seen e.g. Drive ?export=
12+ // take a while to get going. SHAREURL is intended to always be program
13+ // plaintext.
14+ // NOTE(joe + claude): really the timeout maybe should be on idleness at
15+ // startup/between bytes, not overall per completed request, but that's work to
16+ // plumb into `request`
17+ var IMAGE_PROXY_MAX_BYTES = 20 * 1024 * 1024 ; // 20 MB
18+ var IMAGE_PROXY_TIMEOUT_MS = 30 * 1000 ; // 30 s
19+ var SHAREURL_PROXY_MAX_BYTES = 1 * 1024 * 1024 ; // 1 MB
20+ var SHAREURL_PROXY_TIMEOUT_MS = 10 * 1000 ; // 10 s
21+
1022function start ( config , onServerReady ) {
23+ var defaultOpts = {
24+ PYRET : process . env . PYRET ,
25+ BASE_URL : config . baseUrl ,
26+ GOOGLE_API_KEY : config . google . apiKey ,
27+ GOOGLE_APP_ID : config . google . appId ,
28+ LOG_URL : config . logURL ,
29+ LOG_PASSWORD : config . logPassword ,
30+ LOG_USER : config . logUser ,
31+ GIT_REV : config . gitRev ,
32+ GIT_BRANCH : config . gitBranch ,
33+ POSTMESSAGE_ORIGIN : process . env . POSTMESSAGE_ORIGIN
34+ } ;
1135 var express = require ( 'express' ) ;
1236 var cookieSession = require ( 'cookie-session' ) ;
1337 var cookieParser = require ( 'cookie-parser' ) ;
1438 var bodyParser = require ( 'body-parser' ) ;
1539 var csrf = require ( 'csurf' ) ;
1640 var googleAuth = require ( './google-auth.js' ) ;
1741 var request = require ( 'request' ) ;
42+ var requestFilteringAgent = require ( 'request-filtering-agent' ) ;
1843 var mustache = require ( 'mustache-express' ) ;
1944 var url = require ( 'url' ) ;
2045 var fs = require ( 'fs' ) ;
@@ -107,14 +132,8 @@ function start(config, onServerReady) {
107132
108133 app . get ( "/" , function ( req , res ) {
109134 var content = loggedIn ( req ) ? "My Programs" : "Log In" ;
110- res . render ( "index.html" , {
111- PYRET : process . env . PYRET ,
135+ res . render ( "index.html" , { ...defaultOpts ,
112136 LEFT_LINK : content ,
113- GOOGLE_API_KEY : config . google . apiKey ,
114- BASE_URL : config . baseUrl ,
115- LOG_URL : config . logURL ,
116- GIT_REV : config . gitRev ,
117- GIT_BRANCH : config . gitBranch
118137 } ) ;
119138 } ) ;
120139
@@ -180,24 +199,74 @@ function start(config, onServerReady) {
180199 } ) ;
181200 }
182201
183- app . get ( "/downloadImg" , function ( req , response ) {
184- var parsed = url . parse ( req . url ) ;
185- var googleLink = decodeURIComponent ( parsed . query . slice ( 0 ) ) ;
186- var googleParsed = url . parse ( googleLink ) ;
187- var gReq = request ( { url : googleLink , encoding : 'binary' } , function ( error , imgResponse , body ) {
188- if ( error ) {
189- response . status ( 400 ) . send ( { type : "image-load-failure" , error : "Unable to load image " + String ( error ) } ) ;
202+ function proxyStreamFetch ( opts ) {
203+ var res = opts . res ;
204+ res . set ( 'X-Content-Type-Options' , 'nosniff' ) ;
205+ res . set ( 'Content-Security-Policy' , 'sandbox' ) ;
206+
207+ var parsed ;
208+ try { parsed = new URL ( opts . url ) ; }
209+ catch ( e ) { return res . status ( 400 ) . send ( { error : 'invalid-url' } ) ; }
210+ if ( opts . allowedHosts && ! opts . allowedHosts ( parsed . hostname ) ) {
211+ return res . status ( 400 ) . send ( { error : 'host-not-allowed' } ) ;
212+ }
213+
214+ var bytes = 0 ;
215+ var upstream = request ( {
216+ url : opts . url ,
217+ timeout : opts . timeoutMs ,
218+ agent : requestFilteringAgent . useAgent ( opts . url ) ,
219+ followRedirect : function ( resp ) {
220+ if ( ! opts . allowedHosts ) return true ;
221+ try {
222+ var next = new URL ( resp . headers . location , opts . url ) ;
223+ return opts . allowedHosts ( next . hostname ) ;
224+ } catch ( _ ) { return false ; }
225+ } ,
226+ } ) ;
227+ // If the client disconnects (e.g. the browser aborts /load-shareurl after
228+ // direct succeeded), tear down the upstream connection too — otherwise
229+ // we'd keep streaming bytes from raw.githubusercontent.com to nowhere.
230+ res . on ( 'close' , function ( ) { upstream . destroy ( ) ; } ) ;
231+ upstream . on ( 'error' , function ( err ) {
232+ if ( ! res . headersSent ) opts . onError ( res , err ) ;
233+ } ) ;
234+ upstream . on ( 'response' , function ( upRes ) {
235+ if ( opts . contentTypeOk && ! opts . contentTypeOk ( upRes . headers [ 'content-type' ] ) ) {
236+ upstream . destroy ( ) ;
237+ return res . status ( 400 ) . send ( { error : 'content-type-not-allowed' , detail : upRes . headers [ 'content-type' ] } ) ;
190238 }
191- else {
192- var h = imgResponse . headers ;
193- var ct = h [ 'content-type' ] ;
194- if ( ( ! ct ) || ( ct . indexOf ( 'image/' ) !== 0 ) ) {
195- response . status ( 400 ) . send ( { type : "non-image" , error : "Invalid image type " + ct } ) ;
196- return ;
197- }
198- response . set ( 'content-type' , ct ) ;
199- response . end ( body , 'binary' ) ;
239+ res . status ( upRes . statusCode ) ;
240+ if ( upRes . headers [ 'content-type' ] ) {
241+ res . set ( 'content-type' , upRes . headers [ 'content-type' ] ) ;
200242 }
243+ upRes . on ( 'data' , function ( chunk ) {
244+ bytes += chunk . length ;
245+ if ( bytes > opts . maxBytes ) {
246+ upstream . destroy ( ) ;
247+ if ( ! res . headersSent ) res . status ( 502 ) . send ( { error : 'too-large' } ) ;
248+ else res . destroy ( ) ;
249+ }
250+ } ) ;
251+ // Pipe upRes (IncomingMessage), not upstream (request object). The
252+ // request library's .pipe copies upstream headers verbatim, which
253+ // would overwrite the security headers set above.
254+ upRes . pipe ( res ) ;
255+ } ) ;
256+ }
257+
258+ app . get ( "/downloadImg" , function ( req , response ) {
259+ var googleLink = decodeURIComponent ( url . parse ( req . url ) . query . slice ( 0 ) ) ;
260+ proxyStreamFetch ( {
261+ res : response ,
262+ url : googleLink ,
263+ allowedHosts : null ,
264+ maxBytes : IMAGE_PROXY_MAX_BYTES ,
265+ timeoutMs : IMAGE_PROXY_TIMEOUT_MS ,
266+ contentTypeOk : function ( ct ) { return ct && ct . indexOf ( 'image/' ) === 0 ; } ,
267+ onError : function ( res , err ) {
268+ res . status ( 400 ) . send ( { type : 'image-load-failure' , error : 'Unable to load image ' + String ( err ) } ) ;
269+ } ,
201270 } ) ;
202271 } ) ;
203272
@@ -529,30 +598,14 @@ function start(config, onServerReady) {
529598 } ) ;
530599
531600 app . get ( "/editor" , function ( req , res ) {
532- res . render ( "editor.html" , {
533- PYRET : process . env . PYRET ,
534- BASE_URL : config . baseUrl ,
535- GOOGLE_API_KEY : config . google . apiKey ,
536- GOOGLE_APP_ID : config . google . appId ,
601+ res . render ( "editor.html" , { ...defaultOpts ,
537602 CSRF_TOKEN : req . csrfToken ( ) ,
538- LOG_URL : config . logURL ,
539- GIT_REV : config . gitRev ,
540- GIT_BRANCH : config . gitBranch ,
541- POSTMESSAGE_ORIGIN : process . env . POSTMESSAGE_ORIGIN
542603 } ) ;
543604 } ) ;
544605
545606 app . get ( "/blocks" , function ( req , res ) {
546- res . render ( "blocks.html" , {
547- PYRET : process . env . PYRET ,
548- BASE_URL : config . baseUrl ,
549- GOOGLE_API_KEY : config . google . apiKey ,
550- GOOGLE_APP_ID : config . google . appId ,
607+ res . render ( "blocks.html" , { ...defaultOpts ,
551608 CSRF_TOKEN : req . csrfToken ( ) ,
552- LOG_URL : config . logURL ,
553- GIT_REV : config . gitRev ,
554- GIT_BRANCH : config . gitBranch ,
555- POSTMESSAGE_ORIGIN : process . env . POSTMESSAGE_ORIGIN
556609 } ) ;
557610 } ) ;
558611
@@ -575,6 +628,26 @@ function start(config, onServerReady) {
575628
576629 } ) ;
577630
631+ // Server-side proxy for #shareurl loads from hosts that some school networks
632+ // block or will likely block (notably raw.githubusercontent.com).
633+ // Eager-proxied client-side for any URL whose host is in
634+ // SHAREURL_ALLOWED_HOSTS. We can expand this list as needed.
635+ var SHAREURL_ALLOWED_HOSTS = new Set ( [ 'raw.githubusercontent.com' ] ) ;
636+
637+ app . get ( "/load-shareurl" , function ( req , res ) {
638+ proxyStreamFetch ( {
639+ res : res ,
640+ url : req . query . url ,
641+ allowedHosts : function ( h ) { return SHAREURL_ALLOWED_HOSTS . has ( h ) ; } ,
642+ maxBytes : SHAREURL_PROXY_MAX_BYTES ,
643+ timeoutMs : SHAREURL_PROXY_TIMEOUT_MS ,
644+ contentTypeOk : null ,
645+ onError : function ( res , err ) {
646+ res . status ( 502 ) . send ( { error : 'upstream-error' } ) ;
647+ } ,
648+ } ) ;
649+ } ) ;
650+
578651
579652 app . post ( "/share-image" , function ( req , res ) {
580653 var driveFileId = req . body . fileId ;
0 commit comments