- Timestamp:
- Dec 23, 2016, 8:10:33 PM (8 years ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
trunk/morty.go
r68 r69 220 220 ` 221 221 222 var FAVICON_BYTES []byte 223 224 func init() { 225 FaviconBase64 := "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII" 226 227 FAVICON_BYTES, _ = base64.StdEncoding.DecodeString(FaviconBase64) 228 } 229 230 func (p *Proxy) RequestHandler(ctx *fasthttp.RequestCtx) { 231 232 if appRequestHandler(ctx) { 233 return 234 } 235 236 requestHash := popRequestParam(ctx, []byte("mortyhash")) 237 238 requestURI := popRequestParam(ctx, []byte("mortyurl")) 239 240 if requestURI == nil { 241 p.serveMainPage(ctx, 200, nil) 242 return 243 } 244 245 if p.Key != nil { 246 if !verifyRequestURI(requestURI, requestHash, p.Key) { 247 // HTTP status code 403 : Forbidden 248 p.serveMainPage(ctx, 403, errors.New(`invalid "mortyhash" parameter`)) 249 return 250 } 251 } 252 253 parsedURI, err := url.Parse(string(requestURI)) 254 255 if strings.HasSuffix(parsedURI.Host, ".onion") { 256 // HTTP status code 501 : Not Implemented 257 p.serveMainPage(ctx, 501, errors.New("Tor urls are not supported yet")) 258 return 259 } 260 261 if err != nil { 262 // HTTP status code 500 : Internal Server Error 263 p.serveMainPage(ctx, 500, err) 264 return 265 } 266 267 req := fasthttp.AcquireRequest() 268 defer fasthttp.ReleaseRequest(req) 269 req.SetConnectionClose() 270 271 requestURIStr := string(requestURI) 272 273 log.Println("getting", requestURIStr) 274 275 req.SetRequestURI(requestURIStr) 276 req.Header.SetUserAgentBytes([]byte("Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0")) 277 278 resp := fasthttp.AcquireResponse() 279 defer fasthttp.ReleaseResponse(resp) 280 281 req.Header.SetMethodBytes(ctx.Method()) 282 if ctx.IsPost() || ctx.IsPut() { 283 req.SetBody(ctx.PostBody()) 284 } 285 286 err = CLIENT.DoTimeout(req, resp, p.RequestTimeout) 287 288 if err != nil { 289 if err == fasthttp.ErrTimeout { 290 // HTTP status code 504 : Gateway Time-Out 291 p.serveMainPage(ctx, 504, err) 292 } else { 293 // HTTP status code 500 : Internal Server Error 294 p.serveMainPage(ctx, 500, err) 295 } 296 return 297 } 298 299 if resp.StatusCode() != 200 { 300 switch resp.StatusCode() { 301 case 301, 302, 303, 307, 308: 302 loc := resp.Header.Peek("Location") 303 if loc != nil { 304 rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI} 305 url, err := rc.ProxifyURI(loc) 306 if err == nil { 307 ctx.SetStatusCode(resp.StatusCode()) 308 ctx.Response.Header.Add("Location", url) 309 log.Println("redirect to", string(loc)) 310 return 311 } 312 } 313 } 314 error_message := fmt.Sprintf("invalid response: %d (%s)", resp.StatusCode(), requestURIStr) 315 p.serveMainPage(ctx, resp.StatusCode(), errors.New(error_message)) 316 return 317 } 318 319 contentTypeBytes := resp.Header.Peek("Content-Type") 320 321 if contentTypeBytes == nil { 322 // HTTP status code 503 : Service Unavailable 323 p.serveMainPage(ctx, 503, errors.New("invalid content type")) 324 return 325 } 326 327 contentTypeString := string(contentTypeBytes) 328 329 // decode Content-Type header 330 contentType, error := contenttype.ParseContentType(contentTypeString) 331 if error != nil { 332 // HTTP status code 503 : Service Unavailable 333 p.serveMainPage(ctx, 503, errors.New("invalid content type")) 334 return 335 } 336 337 // content-disposition 338 contentDispositionBytes := ctx.Request.Header.Peek("Content-Disposition") 339 340 // check content type 341 if !ALLOWED_CONTENTTYPE_FILTER(contentType) { 342 // it is not a usual content type 343 if ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER(contentType) { 344 // force attachment for allowed content type 345 contentDispositionBytes = contentDispositionForceAttachment(contentDispositionBytes, parsedURI) 346 } else { 347 // deny access to forbidden content type 348 // HTTP status code 403 : Forbidden 349 p.serveMainPage(ctx, 403, errors.New("forbidden content type")) 350 return 351 } 352 } 353 354 // HACK : replace */xhtml by text/html 355 if contentType.SubType == "xhtml" { 356 contentType.TopLevelType = "text" 357 contentType.SubType = "html" 358 contentType.Suffix = "" 359 } 360 361 // conversion to UTF-8 362 var responseBody []byte 363 364 if contentType.TopLevelType == "text" { 365 e, ename, _ := charset.DetermineEncoding(resp.Body(), contentTypeString) 366 if (e != encoding.Nop) && (!strings.EqualFold("utf-8", ename)) { 367 responseBody, err = e.NewDecoder().Bytes(resp.Body()) 368 if err != nil { 369 // HTTP status code 503 : Service Unavailable 370 p.serveMainPage(ctx, 503, err) 371 return 372 } 373 } else { 374 responseBody = resp.Body() 375 } 376 // update the charset or specify it 377 contentType.Parameters["charset"] = "UTF-8" 378 } else { 379 responseBody = resp.Body() 380 } 381 382 // 383 contentType.FilterParameters(ALLOWED_CONTENTTYPE_PARAMETERS) 384 385 // set the content type 386 ctx.SetContentType(contentType.String()) 387 388 // output according to MIME type 389 switch { 390 case contentType.SubType == "css" && contentType.Suffix == "": 391 sanitizeCSS(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody) 392 case contentType.SubType == "html" && contentType.Suffix == "": 393 sanitizeHTML(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody) 394 default: 395 if contentDispositionBytes != nil { 396 ctx.Response.Header.AddBytesV("Content-Disposition", contentDispositionBytes) 397 } 398 ctx.Write(responseBody) 399 } 400 } 401 402 // force content-disposition to attachment 403 func contentDispositionForceAttachment(contentDispositionBytes []byte, url *url.URL) []byte { 404 var contentDispositionParams map[string]string 405 406 if contentDispositionBytes != nil { 407 var err error 408 _, contentDispositionParams, err = mime.ParseMediaType(string(contentDispositionBytes)) 409 if err != nil { 410 contentDispositionParams = make(map[string]string) 411 } 412 } else { 413 contentDispositionParams = make(map[string]string) 414 } 415 416 _, fileNameDefined := contentDispositionParams["filename"] 417 if !fileNameDefined { 418 // TODO : sanitize filename 419 contentDispositionParams["fileName"] = filepath.Base(url.Path) 420 } 421 422 return []byte(mime.FormatMediaType("attachment", contentDispositionParams)) 423 } 424 425 func appRequestHandler(ctx *fasthttp.RequestCtx) bool { 426 // serve robots.txt 427 if bytes.Equal(ctx.Path(), []byte("/robots.txt")) { 428 ctx.SetContentType("text/plain") 429 ctx.Write([]byte("User-Agent: *\nDisallow: /\n")) 430 return true 431 } 432 433 // server favicon.ico 434 if bytes.Equal(ctx.Path(), []byte("/favicon.ico")) { 435 ctx.SetContentType("image/png") 436 ctx.Write(FAVICON_BYTES) 437 return true 438 } 439 440 return false 441 } 442 443 func popRequestParam(ctx *fasthttp.RequestCtx, paramName []byte) []byte { 444 param := ctx.QueryArgs().PeekBytes(paramName) 445 446 if param == nil { 447 param = ctx.PostArgs().PeekBytes(paramName) 448 if param != nil { 449 ctx.PostArgs().DelBytes(paramName) 450 } 451 } else { 452 ctx.QueryArgs().DelBytes(paramName) 453 } 454 455 return param 456 } 457 458 func sanitizeCSS(rc *RequestConfig, out io.Writer, css []byte) { 459 // TODO 460 461 urlSlices := CSS_URL_REGEXP.FindAllSubmatchIndex(css, -1) 462 463 if urlSlices == nil { 464 out.Write(css) 465 return 466 } 467 468 startIndex := 0 469 470 for _, s := range urlSlices { 471 urlStart := s[4] 472 urlEnd := s[5] 473 474 if uri, err := rc.ProxifyURI(css[urlStart:urlEnd]); err == nil { 475 out.Write(css[startIndex:urlStart]) 476 out.Write([]byte(uri)) 477 startIndex = urlEnd 478 } else { 479 log.Println("cannot proxify css uri:", string(css[urlStart:urlEnd])) 480 } 481 } 482 if startIndex < len(css) { 483 out.Write(css[startIndex:len(css)]) 484 } 485 } 486 487 func sanitizeHTML(rc *RequestConfig, out io.Writer, htmlDoc []byte) { 488 r := bytes.NewReader(htmlDoc) 489 decoder := html.NewTokenizer(r) 490 decoder.AllowCDATA(true) 491 492 unsafeElements := make([][]byte, 0, 8) 493 state := STATE_DEFAULT 494 for { 495 token := decoder.Next() 496 if token == html.ErrorToken { 497 err := decoder.Err() 498 if err != io.EOF { 499 log.Println("failed to parse HTML:") 500 } 501 break 502 } 503 504 if len(unsafeElements) == 0 { 505 506 switch token { 507 case html.StartTagToken, html.SelfClosingTagToken: 508 tag, hasAttrs := decoder.TagName() 509 safe := !inArray(tag, UNSAFE_ELEMENTS) 510 if !safe { 511 if !inArray(tag, SELF_CLOSING_ELEMENTS) { 512 var unsafeTag []byte = make([]byte, len(tag)) 513 copy(unsafeTag, tag) 514 unsafeElements = append(unsafeElements, unsafeTag) 515 } 516 break 517 } 518 if bytes.Equal(tag, []byte("base")) { 519 for { 520 attrName, attrValue, moreAttr := decoder.TagAttr() 521 if bytes.Equal(attrName, []byte("href")) { 522 parsedURI, err := url.Parse(string(attrValue)) 523 if err == nil { 524 rc.BaseURL = parsedURI 525 } 526 } 527 if !moreAttr { 528 break 529 } 530 } 531 break 532 } 533 if bytes.Equal(tag, []byte("noscript")) { 534 state = STATE_IN_NOSCRIPT 535 break 536 } 537 var attrs [][][]byte 538 if hasAttrs { 539 for { 540 attrName, attrValue, moreAttr := decoder.TagAttr() 541 attrs = append(attrs, [][]byte{ 542 attrName, 543 attrValue, 544 []byte(html.EscapeString(string(attrValue))), 545 }) 546 if !moreAttr { 547 break 548 } 549 } 550 } 551 if bytes.Equal(tag, []byte("link")) { 552 sanitizeLinkTag(rc, out, attrs) 553 break 554 } 555 556 if bytes.Equal(tag, []byte("meta")) { 557 sanitizeMetaTag(rc, out, attrs) 558 break 559 } 560 561 fmt.Fprintf(out, "<%s", tag) 562 563 if hasAttrs { 564 sanitizeAttrs(rc, out, attrs) 565 } 566 567 if token == html.SelfClosingTagToken { 568 fmt.Fprintf(out, " />") 569 } else { 570 fmt.Fprintf(out, ">") 571 if bytes.Equal(tag, []byte("style")) { 572 state = STATE_IN_STYLE 573 } 574 } 575 576 if bytes.Equal(tag, []byte("head")) { 577 fmt.Fprintf(out, HTML_HEAD_CONTENT_TYPE) 578 } 579 580 if bytes.Equal(tag, []byte("form")) { 581 var formURL *url.URL 582 for _, attr := range attrs { 583 if bytes.Equal(attr[0], []byte("action")) { 584 formURL, _ = url.Parse(string(attr[1])) 585 formURL = mergeURIs(rc.BaseURL, formURL) 586 break 587 } 588 } 589 if formURL == nil { 590 formURL = rc.BaseURL 591 } 592 urlStr := formURL.String() 593 var key string 594 if rc.Key != nil { 595 key = hash(urlStr, rc.Key) 596 } 597 fmt.Fprintf(out, HTML_FORM_EXTENSION, urlStr, key) 598 599 } 600 601 case html.EndTagToken: 602 tag, _ := decoder.TagName() 603 writeEndTag := true 604 switch string(tag) { 605 case "body": 606 fmt.Fprintf(out, HTML_BODY_EXTENSION, rc.BaseURL.String()) 607 case "style": 608 state = STATE_DEFAULT 609 case "noscript": 610 state = STATE_DEFAULT 611 writeEndTag = false 612 } 613 // skip noscript tags - only the tag, not the content, because javascript is sanitized 614 if writeEndTag { 615 fmt.Fprintf(out, "</%s>", tag) 616 } 617 618 case html.TextToken: 619 switch state { 620 case STATE_DEFAULT: 621 fmt.Fprintf(out, "%s", decoder.Raw()) 622 case STATE_IN_STYLE: 623 sanitizeCSS(rc, out, decoder.Raw()) 624 case STATE_IN_NOSCRIPT: 625 sanitizeHTML(rc, out, decoder.Raw()) 626 } 627 628 case html.CommentToken: 629 // ignore comment. TODO : parse IE conditional comment 630 631 case html.DoctypeToken: 632 out.Write(decoder.Raw()) 633 } 634 } else { 635 switch token { 636 case html.StartTagToken: 637 tag, _ := decoder.TagName() 638 if inArray(tag, UNSAFE_ELEMENTS) { 639 unsafeElements = append(unsafeElements, tag) 640 } 641 642 case html.EndTagToken: 643 tag, _ := decoder.TagName() 644 if bytes.Equal(unsafeElements[len(unsafeElements)-1], tag) { 645 unsafeElements = unsafeElements[:len(unsafeElements)-1] 646 } 647 } 648 } 649 } 650 } 651 652 func sanitizeLinkTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 653 exclude := false 654 for _, attr := range attrs { 655 attrName := attr[0] 656 attrValue := attr[1] 657 if bytes.Equal(attrName, []byte("rel")) { 658 if !inArray(attrValue, LINK_REL_SAFE_VALUES) { 659 exclude = true 660 break 661 } 662 } 663 if bytes.Equal(attrName, []byte("as")) { 664 if bytes.Equal(attrValue, []byte("script")) { 665 exclude = true 666 break 667 } 668 } 669 } 670 671 if !exclude { 672 out.Write([]byte("<link")) 673 for _, attr := range attrs { 674 sanitizeAttr(rc, out, attr[0], attr[1], attr[2]) 675 } 676 out.Write([]byte(">")) 677 } 678 } 679 680 func sanitizeMetaTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 681 var http_equiv []byte 682 var content []byte 683 684 for _, attr := range attrs { 685 attrName := attr[0] 686 attrValue := attr[1] 687 if bytes.Equal(attrName, []byte("http-equiv")) { 688 http_equiv = bytes.ToLower(attrValue) 689 // exclude some <meta http-equiv="..." ..> 690 if !inArray(http_equiv, LINK_HTTP_EQUIV_SAFE_VALUES) { 691 return 692 } 693 } 694 if bytes.Equal(attrName, []byte("content")) { 695 content = attrValue 696 } 697 if bytes.Equal(attrName, []byte("charset")) { 698 // exclude <meta charset="..."> 699 return 700 } 701 } 702 703 out.Write([]byte("<meta")) 704 urlIndex := bytes.Index(bytes.ToLower(content), []byte("url=")) 705 if bytes.Equal(http_equiv, []byte("refresh")) && urlIndex != -1 { 706 contentUrl := content[urlIndex+4:] 707 // special case of <meta http-equiv="refresh" content="0; url='example.com/url.with.quote.outside'"> 708 if len(contentUrl) >= 2 && (contentUrl[0] == byte('\'') || contentUrl[0] == byte('"')) { 709 if contentUrl[0] == contentUrl[len(contentUrl)-1] { 710 contentUrl = contentUrl[1 : len(contentUrl)-1] 711 } 712 } 713 // output proxify result 714 if uri, err := rc.ProxifyURI(contentUrl); err == nil { 715 fmt.Fprintf(out, ` http-equiv="refresh" content="%surl=%s"`, content[:urlIndex], uri) 716 } 717 } else { 718 if len(http_equiv) > 0 { 719 fmt.Fprintf(out, ` http-equiv="%s"`, http_equiv) 720 } 721 sanitizeAttrs(rc, out, attrs) 722 } 723 out.Write([]byte(">")) 724 } 725 726 func sanitizeAttrs(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 727 for _, attr := range attrs { 728 sanitizeAttr(rc, out, attr[0], attr[1], attr[2]) 729 } 730 } 731 732 func sanitizeAttr(rc *RequestConfig, out io.Writer, attrName, attrValue, escapedAttrValue []byte) { 733 if inArray(attrName, SAFE_ATTRIBUTES) { 734 fmt.Fprintf(out, " %s=\"%s\"", attrName, escapedAttrValue) 735 return 736 } 737 switch string(attrName) { 738 case "src", "href", "action": 739 if uri, err := rc.ProxifyURI(attrValue); err == nil { 740 fmt.Fprintf(out, " %s=\"%s\"", attrName, uri) 741 } else { 742 log.Println("cannot proxify uri:", string(attrValue)) 743 } 744 case "style": 745 cssAttr := bytes.NewBuffer(nil) 746 sanitizeCSS(rc, cssAttr, attrValue) 747 fmt.Fprintf(out, " %s=\"%s\"", attrName, html.EscapeString(string(cssAttr.Bytes()))) 748 } 749 } 750 751 func mergeURIs(u1, u2 *url.URL) *url.URL { 752 return u1.ResolveReference(u2) 753 } 754 755 // Sanitized URI : removes all runes bellow 32 (included) as the begining and end of URI, and lower case the scheme. 756 // avoid memory allocation (except for the scheme) 757 func sanitizeURI(uri []byte) ([]byte, string) { 758 first_rune_index := 0 759 first_rune_seen := false 760 scheme_last_index := -1 761 buffer := bytes.NewBuffer(make([]byte, 0, 10)) 762 763 // remove trailing space and special characters 764 uri = bytes.TrimRight(uri, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x20") 765 766 // loop over byte by byte 767 for i, c := range uri { 768 // ignore special characters and space (c <= 32) 769 if c > 32 { 770 // append to the lower case of the rune to buffer 771 if c < utf8.RuneSelf && 'A' <= c && c <= 'Z' { 772 c = c + 'a' - 'A' 773 } 774 775 buffer.WriteByte(c) 776 777 // update the first rune index that is not a special rune 778 if !first_rune_seen { 779 first_rune_index = i 780 first_rune_seen = true 781 } 782 783 if c == ':' { 784 // colon rune found, we have found the scheme 785 scheme_last_index = i 786 break 787 } else if c == '/' || c == '?' || c == '\\' || c == '#' { 788 // special case : most probably a relative URI 789 break 790 } 791 } 792 } 793 794 if scheme_last_index != -1 { 795 // scheme found 796 // copy the "lower case without special runes scheme" before the ":" rune 797 scheme_start_index := scheme_last_index - buffer.Len() + 1 798 copy(uri[scheme_start_index:], buffer.Bytes()) 799 // and return the result 800 return uri[scheme_start_index:], buffer.String() 801 } else { 802 // scheme NOT found 803 return uri[first_rune_index:], "" 804 } 805 } 806 807 func (rc *RequestConfig) ProxifyURI(uri []byte) (string, error) { 808 // sanitize URI 809 uri, scheme := sanitizeURI(uri) 810 811 // remove javascript protocol 812 if scheme == "javascript:" { 813 return "", nil 814 } 815 816 // TODO check malicious data: - e.g. data:script 817 if scheme == "data:" { 818 if bytes.HasPrefix(uri, []byte("data:image/png")) || 819 bytes.HasPrefix(uri, []byte("data:image/jpeg")) || 820 bytes.HasPrefix(uri, []byte("data:image/pjpeg")) || 821 bytes.HasPrefix(uri, []byte("data:image/gif")) || 822 bytes.HasPrefix(uri, []byte("data:image/webp")) { 823 // should be safe 824 return string(uri), nil 825 } else { 826 // unsafe data 827 return "", nil 828 } 829 } 830 831 // parse the uri 832 u, err := url.Parse(string(uri)) 833 if err != nil { 834 return "", err 835 } 836 837 // get the fragment (with the prefix "#") 838 fragment := "" 839 if len(u.Fragment) > 0 { 840 fragment = "#" + u.Fragment 841 } 842 843 // reset the fragment: it is not included in the mortyurl 844 u.Fragment = "" 845 846 // merge the URI with the document URI 847 u = mergeURIs(rc.BaseURL, u) 848 849 // simple internal link ? 850 // some web pages describe the whole link https://same:auth@same.host/same.path?same.query#new.fragment 851 if u.Scheme == rc.BaseURL.Scheme && 852 (rc.BaseURL.User == nil || (u.User != nil && u.User.String() == rc.BaseURL.User.String())) && 853 u.Host == rc.BaseURL.Host && 854 u.Path == rc.BaseURL.Path && 855 u.RawQuery == rc.BaseURL.RawQuery { 856 // the fragment is the only difference between the document URI and the uri parameter 857 return fragment, nil 858 } 859 860 // return full URI and fragment (if not empty) 861 morty_uri := u.String() 862 863 if rc.Key == nil { 864 return fmt.Sprintf("./?mortyurl=%s%s", url.QueryEscape(morty_uri), fragment), nil 865 } 866 return fmt.Sprintf("./?mortyhash=%s&mortyurl=%s%s", hash(morty_uri, rc.Key), url.QueryEscape(morty_uri), fragment), nil 867 } 868 869 func inArray(b []byte, a [][]byte) bool { 870 for _, b2 := range a { 871 if bytes.Equal(b, b2) { 872 return true 873 } 874 } 875 return false 876 } 877 878 func hash(msg string, key []byte) string { 879 mac := hmac.New(sha256.New, key) 880 mac.Write([]byte(msg)) 881 return hex.EncodeToString(mac.Sum(nil)) 882 } 883 884 func verifyRequestURI(uri, hashMsg, key []byte) bool { 885 h := make([]byte, hex.DecodedLen(len(hashMsg))) 886 _, err := hex.Decode(h, hashMsg) 887 if err != nil { 888 log.Println("hmac error:", err) 889 return false 890 } 891 mac := hmac.New(sha256.New, key) 892 mac.Write(uri) 893 return hmac.Equal(h, mac.Sum(nil)) 894 } 895 896 func (p *Proxy) serveMainPage(ctx *fasthttp.RequestCtx, statusCode int, err error) { 897 ctx.SetContentType("text/html; charset=UTF-8") 898 ctx.SetStatusCode(statusCode) 899 ctx.Write([]byte(`<!doctype html> 222 var MORTY_HTML_PAGE_START string = `<!doctype html> 900 223 <html> 901 224 <head> … … 918 241 <div class="container"> 919 242 <h1>MortyProxy</h1> 920 `)) 243 ` 244 245 var MORTY_HTML_PAGE_END string = ` 246 </div> 247 <div class="footer"> 248 <p>Morty rewrites web pages to exclude malicious HTML tags and CSS/HTML attributes. It also replaces external resource references to prevent third-party information leaks.<br /> 249 <a href="https://github.com/asciimoo/morty">view on github</a> 250 </p> 251 </div> 252 </body> 253 </html>` 254 255 var FAVICON_BYTES []byte 256 257 func init() { 258 FaviconBase64 := "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII" 259 260 FAVICON_BYTES, _ = base64.StdEncoding.DecodeString(FaviconBase64) 261 } 262 263 func (p *Proxy) RequestHandler(ctx *fasthttp.RequestCtx) { 264 265 if appRequestHandler(ctx) { 266 return 267 } 268 269 requestHash := popRequestParam(ctx, []byte("mortyhash")) 270 271 requestURI := popRequestParam(ctx, []byte("mortyurl")) 272 273 if requestURI == nil { 274 p.serveMainPage(ctx, 200, nil) 275 return 276 } 277 278 if p.Key != nil { 279 if !verifyRequestURI(requestURI, requestHash, p.Key) { 280 // HTTP status code 403 : Forbidden 281 p.serveMainPage(ctx, 403, errors.New(`invalid "mortyhash" parameter`)) 282 return 283 } 284 } 285 286 parsedURI, err := url.Parse(string(requestURI)) 287 288 if err != nil { 289 // HTTP status code 500 : Internal Server Error 290 p.serveMainPage(ctx, 500, err) 291 return 292 } 293 294 // Serve an intermediate page for protocols other than HTTP(S) 295 if (parsedURI.Scheme != "http" && parsedURI.Scheme != "https") || strings.HasSuffix(parsedURI.Host, ".onion") { 296 p.serveExitMortyPage(ctx, parsedURI) 297 return 298 } 299 300 req := fasthttp.AcquireRequest() 301 defer fasthttp.ReleaseRequest(req) 302 req.SetConnectionClose() 303 304 requestURIStr := string(requestURI) 305 306 log.Println("getting", requestURIStr) 307 308 req.SetRequestURI(requestURIStr) 309 req.Header.SetUserAgentBytes([]byte("Mozilla/5.0 (Windows NT 10.0; WOW64; rv:50.0) Gecko/20100101 Firefox/50.0")) 310 311 resp := fasthttp.AcquireResponse() 312 defer fasthttp.ReleaseResponse(resp) 313 314 req.Header.SetMethodBytes(ctx.Method()) 315 if ctx.IsPost() || ctx.IsPut() { 316 req.SetBody(ctx.PostBody()) 317 } 318 319 err = CLIENT.DoTimeout(req, resp, p.RequestTimeout) 320 321 if err != nil { 322 if err == fasthttp.ErrTimeout { 323 // HTTP status code 504 : Gateway Time-Out 324 p.serveMainPage(ctx, 504, err) 325 } else { 326 // HTTP status code 500 : Internal Server Error 327 p.serveMainPage(ctx, 500, err) 328 } 329 return 330 } 331 332 if resp.StatusCode() != 200 { 333 switch resp.StatusCode() { 334 case 301, 302, 303, 307, 308: 335 loc := resp.Header.Peek("Location") 336 if loc != nil { 337 rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI} 338 url, err := rc.ProxifyURI(loc) 339 if err == nil { 340 ctx.SetStatusCode(resp.StatusCode()) 341 ctx.Response.Header.Add("Location", url) 342 log.Println("redirect to", string(loc)) 343 return 344 } 345 } 346 } 347 error_message := fmt.Sprintf("invalid response: %d (%s)", resp.StatusCode(), requestURIStr) 348 p.serveMainPage(ctx, resp.StatusCode(), errors.New(error_message)) 349 return 350 } 351 352 contentTypeBytes := resp.Header.Peek("Content-Type") 353 354 if contentTypeBytes == nil { 355 // HTTP status code 503 : Service Unavailable 356 p.serveMainPage(ctx, 503, errors.New("invalid content type")) 357 return 358 } 359 360 contentTypeString := string(contentTypeBytes) 361 362 // decode Content-Type header 363 contentType, error := contenttype.ParseContentType(contentTypeString) 364 if error != nil { 365 // HTTP status code 503 : Service Unavailable 366 p.serveMainPage(ctx, 503, errors.New("invalid content type")) 367 return 368 } 369 370 // content-disposition 371 contentDispositionBytes := ctx.Request.Header.Peek("Content-Disposition") 372 373 // check content type 374 if !ALLOWED_CONTENTTYPE_FILTER(contentType) { 375 // it is not a usual content type 376 if ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER(contentType) { 377 // force attachment for allowed content type 378 contentDispositionBytes = contentDispositionForceAttachment(contentDispositionBytes, parsedURI) 379 } else { 380 // deny access to forbidden content type 381 // HTTP status code 403 : Forbidden 382 p.serveMainPage(ctx, 403, errors.New("forbidden content type")) 383 return 384 } 385 } 386 387 // HACK : replace */xhtml by text/html 388 if contentType.SubType == "xhtml" { 389 contentType.TopLevelType = "text" 390 contentType.SubType = "html" 391 contentType.Suffix = "" 392 } 393 394 // conversion to UTF-8 395 var responseBody []byte 396 397 if contentType.TopLevelType == "text" { 398 e, ename, _ := charset.DetermineEncoding(resp.Body(), contentTypeString) 399 if (e != encoding.Nop) && (!strings.EqualFold("utf-8", ename)) { 400 responseBody, err = e.NewDecoder().Bytes(resp.Body()) 401 if err != nil { 402 // HTTP status code 503 : Service Unavailable 403 p.serveMainPage(ctx, 503, err) 404 return 405 } 406 } else { 407 responseBody = resp.Body() 408 } 409 // update the charset or specify it 410 contentType.Parameters["charset"] = "UTF-8" 411 } else { 412 responseBody = resp.Body() 413 } 414 415 // 416 contentType.FilterParameters(ALLOWED_CONTENTTYPE_PARAMETERS) 417 418 // set the content type 419 ctx.SetContentType(contentType.String()) 420 421 // output according to MIME type 422 switch { 423 case contentType.SubType == "css" && contentType.Suffix == "": 424 sanitizeCSS(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody) 425 case contentType.SubType == "html" && contentType.Suffix == "": 426 sanitizeHTML(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody) 427 default: 428 if contentDispositionBytes != nil { 429 ctx.Response.Header.AddBytesV("Content-Disposition", contentDispositionBytes) 430 } 431 ctx.Write(responseBody) 432 } 433 } 434 435 // force content-disposition to attachment 436 func contentDispositionForceAttachment(contentDispositionBytes []byte, url *url.URL) []byte { 437 var contentDispositionParams map[string]string 438 439 if contentDispositionBytes != nil { 440 var err error 441 _, contentDispositionParams, err = mime.ParseMediaType(string(contentDispositionBytes)) 442 if err != nil { 443 contentDispositionParams = make(map[string]string) 444 } 445 } else { 446 contentDispositionParams = make(map[string]string) 447 } 448 449 _, fileNameDefined := contentDispositionParams["filename"] 450 if !fileNameDefined { 451 // TODO : sanitize filename 452 contentDispositionParams["fileName"] = filepath.Base(url.Path) 453 } 454 455 return []byte(mime.FormatMediaType("attachment", contentDispositionParams)) 456 } 457 458 func appRequestHandler(ctx *fasthttp.RequestCtx) bool { 459 // serve robots.txt 460 if bytes.Equal(ctx.Path(), []byte("/robots.txt")) { 461 ctx.SetContentType("text/plain") 462 ctx.Write([]byte("User-Agent: *\nDisallow: /\n")) 463 return true 464 } 465 466 // server favicon.ico 467 if bytes.Equal(ctx.Path(), []byte("/favicon.ico")) { 468 ctx.SetContentType("image/png") 469 ctx.Write(FAVICON_BYTES) 470 return true 471 } 472 473 return false 474 } 475 476 func popRequestParam(ctx *fasthttp.RequestCtx, paramName []byte) []byte { 477 param := ctx.QueryArgs().PeekBytes(paramName) 478 479 if param == nil { 480 param = ctx.PostArgs().PeekBytes(paramName) 481 if param != nil { 482 ctx.PostArgs().DelBytes(paramName) 483 } 484 } else { 485 ctx.QueryArgs().DelBytes(paramName) 486 } 487 488 return param 489 } 490 491 func sanitizeCSS(rc *RequestConfig, out io.Writer, css []byte) { 492 // TODO 493 494 urlSlices := CSS_URL_REGEXP.FindAllSubmatchIndex(css, -1) 495 496 if urlSlices == nil { 497 out.Write(css) 498 return 499 } 500 501 startIndex := 0 502 503 for _, s := range urlSlices { 504 urlStart := s[4] 505 urlEnd := s[5] 506 507 if uri, err := rc.ProxifyURI(css[urlStart:urlEnd]); err == nil { 508 out.Write(css[startIndex:urlStart]) 509 out.Write([]byte(uri)) 510 startIndex = urlEnd 511 } else { 512 log.Println("cannot proxify css uri:", string(css[urlStart:urlEnd])) 513 } 514 } 515 if startIndex < len(css) { 516 out.Write(css[startIndex:len(css)]) 517 } 518 } 519 520 func sanitizeHTML(rc *RequestConfig, out io.Writer, htmlDoc []byte) { 521 r := bytes.NewReader(htmlDoc) 522 decoder := html.NewTokenizer(r) 523 decoder.AllowCDATA(true) 524 525 unsafeElements := make([][]byte, 0, 8) 526 state := STATE_DEFAULT 527 for { 528 token := decoder.Next() 529 if token == html.ErrorToken { 530 err := decoder.Err() 531 if err != io.EOF { 532 log.Println("failed to parse HTML:") 533 } 534 break 535 } 536 537 if len(unsafeElements) == 0 { 538 539 switch token { 540 case html.StartTagToken, html.SelfClosingTagToken: 541 tag, hasAttrs := decoder.TagName() 542 safe := !inArray(tag, UNSAFE_ELEMENTS) 543 if !safe { 544 if !inArray(tag, SELF_CLOSING_ELEMENTS) { 545 var unsafeTag []byte = make([]byte, len(tag)) 546 copy(unsafeTag, tag) 547 unsafeElements = append(unsafeElements, unsafeTag) 548 } 549 break 550 } 551 if bytes.Equal(tag, []byte("base")) { 552 for { 553 attrName, attrValue, moreAttr := decoder.TagAttr() 554 if bytes.Equal(attrName, []byte("href")) { 555 parsedURI, err := url.Parse(string(attrValue)) 556 if err == nil { 557 rc.BaseURL = parsedURI 558 } 559 } 560 if !moreAttr { 561 break 562 } 563 } 564 break 565 } 566 if bytes.Equal(tag, []byte("noscript")) { 567 state = STATE_IN_NOSCRIPT 568 break 569 } 570 var attrs [][][]byte 571 if hasAttrs { 572 for { 573 attrName, attrValue, moreAttr := decoder.TagAttr() 574 attrs = append(attrs, [][]byte{ 575 attrName, 576 attrValue, 577 []byte(html.EscapeString(string(attrValue))), 578 }) 579 if !moreAttr { 580 break 581 } 582 } 583 } 584 if bytes.Equal(tag, []byte("link")) { 585 sanitizeLinkTag(rc, out, attrs) 586 break 587 } 588 589 if bytes.Equal(tag, []byte("meta")) { 590 sanitizeMetaTag(rc, out, attrs) 591 break 592 } 593 594 fmt.Fprintf(out, "<%s", tag) 595 596 if hasAttrs { 597 sanitizeAttrs(rc, out, attrs) 598 } 599 600 if token == html.SelfClosingTagToken { 601 fmt.Fprintf(out, " />") 602 } else { 603 fmt.Fprintf(out, ">") 604 if bytes.Equal(tag, []byte("style")) { 605 state = STATE_IN_STYLE 606 } 607 } 608 609 if bytes.Equal(tag, []byte("head")) { 610 fmt.Fprintf(out, HTML_HEAD_CONTENT_TYPE) 611 } 612 613 if bytes.Equal(tag, []byte("form")) { 614 var formURL *url.URL 615 for _, attr := range attrs { 616 if bytes.Equal(attr[0], []byte("action")) { 617 formURL, _ = url.Parse(string(attr[1])) 618 formURL = mergeURIs(rc.BaseURL, formURL) 619 break 620 } 621 } 622 if formURL == nil { 623 formURL = rc.BaseURL 624 } 625 urlStr := formURL.String() 626 var key string 627 if rc.Key != nil { 628 key = hash(urlStr, rc.Key) 629 } 630 fmt.Fprintf(out, HTML_FORM_EXTENSION, urlStr, key) 631 632 } 633 634 case html.EndTagToken: 635 tag, _ := decoder.TagName() 636 writeEndTag := true 637 switch string(tag) { 638 case "body": 639 fmt.Fprintf(out, HTML_BODY_EXTENSION, rc.BaseURL.String()) 640 case "style": 641 state = STATE_DEFAULT 642 case "noscript": 643 state = STATE_DEFAULT 644 writeEndTag = false 645 } 646 // skip noscript tags - only the tag, not the content, because javascript is sanitized 647 if writeEndTag { 648 fmt.Fprintf(out, "</%s>", tag) 649 } 650 651 case html.TextToken: 652 switch state { 653 case STATE_DEFAULT: 654 fmt.Fprintf(out, "%s", decoder.Raw()) 655 case STATE_IN_STYLE: 656 sanitizeCSS(rc, out, decoder.Raw()) 657 case STATE_IN_NOSCRIPT: 658 sanitizeHTML(rc, out, decoder.Raw()) 659 } 660 661 case html.CommentToken: 662 // ignore comment. TODO : parse IE conditional comment 663 664 case html.DoctypeToken: 665 out.Write(decoder.Raw()) 666 } 667 } else { 668 switch token { 669 case html.StartTagToken: 670 tag, _ := decoder.TagName() 671 if inArray(tag, UNSAFE_ELEMENTS) { 672 unsafeElements = append(unsafeElements, tag) 673 } 674 675 case html.EndTagToken: 676 tag, _ := decoder.TagName() 677 if bytes.Equal(unsafeElements[len(unsafeElements)-1], tag) { 678 unsafeElements = unsafeElements[:len(unsafeElements)-1] 679 } 680 } 681 } 682 } 683 } 684 685 func sanitizeLinkTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 686 exclude := false 687 for _, attr := range attrs { 688 attrName := attr[0] 689 attrValue := attr[1] 690 if bytes.Equal(attrName, []byte("rel")) { 691 if !inArray(attrValue, LINK_REL_SAFE_VALUES) { 692 exclude = true 693 break 694 } 695 } 696 if bytes.Equal(attrName, []byte("as")) { 697 if bytes.Equal(attrValue, []byte("script")) { 698 exclude = true 699 break 700 } 701 } 702 } 703 704 if !exclude { 705 out.Write([]byte("<link")) 706 for _, attr := range attrs { 707 sanitizeAttr(rc, out, attr[0], attr[1], attr[2]) 708 } 709 out.Write([]byte(">")) 710 } 711 } 712 713 func sanitizeMetaTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 714 var http_equiv []byte 715 var content []byte 716 717 for _, attr := range attrs { 718 attrName := attr[0] 719 attrValue := attr[1] 720 if bytes.Equal(attrName, []byte("http-equiv")) { 721 http_equiv = bytes.ToLower(attrValue) 722 // exclude some <meta http-equiv="..." ..> 723 if !inArray(http_equiv, LINK_HTTP_EQUIV_SAFE_VALUES) { 724 return 725 } 726 } 727 if bytes.Equal(attrName, []byte("content")) { 728 content = attrValue 729 } 730 if bytes.Equal(attrName, []byte("charset")) { 731 // exclude <meta charset="..."> 732 return 733 } 734 } 735 736 out.Write([]byte("<meta")) 737 urlIndex := bytes.Index(bytes.ToLower(content), []byte("url=")) 738 if bytes.Equal(http_equiv, []byte("refresh")) && urlIndex != -1 { 739 contentUrl := content[urlIndex+4:] 740 // special case of <meta http-equiv="refresh" content="0; url='example.com/url.with.quote.outside'"> 741 if len(contentUrl) >= 2 && (contentUrl[0] == byte('\'') || contentUrl[0] == byte('"')) { 742 if contentUrl[0] == contentUrl[len(contentUrl)-1] { 743 contentUrl = contentUrl[1 : len(contentUrl)-1] 744 } 745 } 746 // output proxify result 747 if uri, err := rc.ProxifyURI(contentUrl); err == nil { 748 fmt.Fprintf(out, ` http-equiv="refresh" content="%surl=%s"`, content[:urlIndex], uri) 749 } 750 } else { 751 if len(http_equiv) > 0 { 752 fmt.Fprintf(out, ` http-equiv="%s"`, http_equiv) 753 } 754 sanitizeAttrs(rc, out, attrs) 755 } 756 out.Write([]byte(">")) 757 } 758 759 func sanitizeAttrs(rc *RequestConfig, out io.Writer, attrs [][][]byte) { 760 for _, attr := range attrs { 761 sanitizeAttr(rc, out, attr[0], attr[1], attr[2]) 762 } 763 } 764 765 func sanitizeAttr(rc *RequestConfig, out io.Writer, attrName, attrValue, escapedAttrValue []byte) { 766 if inArray(attrName, SAFE_ATTRIBUTES) { 767 fmt.Fprintf(out, " %s=\"%s\"", attrName, escapedAttrValue) 768 return 769 } 770 switch string(attrName) { 771 case "src", "href", "action": 772 if uri, err := rc.ProxifyURI(attrValue); err == nil { 773 fmt.Fprintf(out, " %s=\"%s\"", attrName, uri) 774 } else { 775 log.Println("cannot proxify uri:", string(attrValue)) 776 } 777 case "style": 778 cssAttr := bytes.NewBuffer(nil) 779 sanitizeCSS(rc, cssAttr, attrValue) 780 fmt.Fprintf(out, " %s=\"%s\"", attrName, html.EscapeString(string(cssAttr.Bytes()))) 781 } 782 } 783 784 func mergeURIs(u1, u2 *url.URL) *url.URL { 785 return u1.ResolveReference(u2) 786 } 787 788 // Sanitized URI : removes all runes bellow 32 (included) as the begining and end of URI, and lower case the scheme. 789 // avoid memory allocation (except for the scheme) 790 func sanitizeURI(uri []byte) ([]byte, string) { 791 first_rune_index := 0 792 first_rune_seen := false 793 scheme_last_index := -1 794 buffer := bytes.NewBuffer(make([]byte, 0, 10)) 795 796 // remove trailing space and special characters 797 uri = bytes.TrimRight(uri, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x20") 798 799 // loop over byte by byte 800 for i, c := range uri { 801 // ignore special characters and space (c <= 32) 802 if c > 32 { 803 // append to the lower case of the rune to buffer 804 if c < utf8.RuneSelf && 'A' <= c && c <= 'Z' { 805 c = c + 'a' - 'A' 806 } 807 808 buffer.WriteByte(c) 809 810 // update the first rune index that is not a special rune 811 if !first_rune_seen { 812 first_rune_index = i 813 first_rune_seen = true 814 } 815 816 if c == ':' { 817 // colon rune found, we have found the scheme 818 scheme_last_index = i 819 break 820 } else if c == '/' || c == '?' || c == '\\' || c == '#' { 821 // special case : most probably a relative URI 822 break 823 } 824 } 825 } 826 827 if scheme_last_index != -1 { 828 // scheme found 829 // copy the "lower case without special runes scheme" before the ":" rune 830 scheme_start_index := scheme_last_index - buffer.Len() + 1 831 copy(uri[scheme_start_index:], buffer.Bytes()) 832 // and return the result 833 return uri[scheme_start_index:], buffer.String() 834 } else { 835 // scheme NOT found 836 return uri[first_rune_index:], "" 837 } 838 } 839 840 func (rc *RequestConfig) ProxifyURI(uri []byte) (string, error) { 841 // sanitize URI 842 uri, scheme := sanitizeURI(uri) 843 844 // remove javascript protocol 845 if scheme == "javascript:" { 846 return "", nil 847 } 848 849 // TODO check malicious data: - e.g. data:script 850 if scheme == "data:" { 851 if bytes.HasPrefix(uri, []byte("data:image/png")) || 852 bytes.HasPrefix(uri, []byte("data:image/jpeg")) || 853 bytes.HasPrefix(uri, []byte("data:image/pjpeg")) || 854 bytes.HasPrefix(uri, []byte("data:image/gif")) || 855 bytes.HasPrefix(uri, []byte("data:image/webp")) { 856 // should be safe 857 return string(uri), nil 858 } else { 859 // unsafe data 860 return "", nil 861 } 862 } 863 864 // parse the uri 865 u, err := url.Parse(string(uri)) 866 if err != nil { 867 return "", err 868 } 869 870 // get the fragment (with the prefix "#") 871 fragment := "" 872 if len(u.Fragment) > 0 { 873 fragment = "#" + u.Fragment 874 } 875 876 // reset the fragment: it is not included in the mortyurl 877 u.Fragment = "" 878 879 // merge the URI with the document URI 880 u = mergeURIs(rc.BaseURL, u) 881 882 // simple internal link ? 883 // some web pages describe the whole link https://same:auth@same.host/same.path?same.query#new.fragment 884 if u.Scheme == rc.BaseURL.Scheme && 885 (rc.BaseURL.User == nil || (u.User != nil && u.User.String() == rc.BaseURL.User.String())) && 886 u.Host == rc.BaseURL.Host && 887 u.Path == rc.BaseURL.Path && 888 u.RawQuery == rc.BaseURL.RawQuery { 889 // the fragment is the only difference between the document URI and the uri parameter 890 return fragment, nil 891 } 892 893 // return full URI and fragment (if not empty) 894 morty_uri := u.String() 895 896 if rc.Key == nil { 897 return fmt.Sprintf("./?mortyurl=%s%s", url.QueryEscape(morty_uri), fragment), nil 898 } 899 return fmt.Sprintf("./?mortyhash=%s&mortyurl=%s%s", hash(morty_uri, rc.Key), url.QueryEscape(morty_uri), fragment), nil 900 } 901 902 func inArray(b []byte, a [][]byte) bool { 903 for _, b2 := range a { 904 if bytes.Equal(b, b2) { 905 return true 906 } 907 } 908 return false 909 } 910 911 func hash(msg string, key []byte) string { 912 mac := hmac.New(sha256.New, key) 913 mac.Write([]byte(msg)) 914 return hex.EncodeToString(mac.Sum(nil)) 915 } 916 917 func verifyRequestURI(uri, hashMsg, key []byte) bool { 918 h := make([]byte, hex.DecodedLen(len(hashMsg))) 919 _, err := hex.Decode(h, hashMsg) 920 if err != nil { 921 log.Println("hmac error:", err) 922 return false 923 } 924 mac := hmac.New(sha256.New, key) 925 mac.Write(uri) 926 return hmac.Equal(h, mac.Sum(nil)) 927 } 928 929 func (p *Proxy) serveExitMortyPage(ctx *fasthttp.RequestCtx, uri *url.URL) { 930 ctx.SetContentType("text/html") 931 ctx.SetStatusCode(403) 932 ctx.Write([]byte(MORTY_HTML_PAGE_START)) 933 ctx.Write([]byte("<h2>You are about to exit MortyProxy</h2>")) 934 ctx.Write([]byte("<p>Following</p><p><a href=\"")) 935 ctx.Write([]byte(html.EscapeString(uri.String()))) 936 ctx.Write([]byte("\" rel=\"noreferrer\">")) 937 ctx.Write([]byte(html.EscapeString(uri.String()))) 938 ctx.Write([]byte("</a></p><p>the content of this URL will be <b>NOT</b> sanitized.</p>")) 939 ctx.Write([]byte(MORTY_HTML_PAGE_END)) 940 } 941 942 func (p *Proxy) serveMainPage(ctx *fasthttp.RequestCtx, statusCode int, err error) { 943 ctx.SetContentType("text/html; charset=UTF-8") 944 ctx.SetStatusCode(statusCode) 945 ctx.Write([]byte(MORTY_HTML_PAGE_START)) 921 946 if err != nil { 922 947 log.Println("error:", err) … … 934 959 ctx.Write([]byte(`<h3>Warning! This instance does not support direct URL opening.</h3>`)) 935 960 } 936 ctx.Write([]byte(` 937 </div> 938 <div class="footer"> 939 <p>Morty rewrites web pages to exclude malicious HTML tags and CSS/HTML attributes. It also replaces external resource references to prevent third-party information leaks.<br /> 940 <a href="https://github.com/asciimoo/morty">view on github</a> 941 </p> 942 </div> 943 </body> 944 </html>`)) 961 ctx.Write([]byte(MORTY_HTML_PAGE_END)) 945 962 } 946 963
Note:
See TracChangeset
for help on using the changeset viewer.