Index: /trunk/Makefile
===================================================================
--- /trunk/Makefile	(revision 146)
+++ /trunk/Makefile	(revision 147)
@@ -20,5 +20,5 @@
 
 yukari: vendor
-	$(GO) build $(GOFLAGS) -o $@
+	$(GO) build $(GOFLAGS) ./cmd/yukari
 clean:
 	$(RM) -f yukari
Index: /trunk/README.md
===================================================================
--- /trunk/README.md	(revision 146)
+++ /trunk/README.md	(revision 147)
@@ -1,24 +1,24 @@
-# Yukari
+# Yukari's Gap
 
-Web content sanitizer proxy as a service, fork of [MortyProxy](https://github.com/asciimoo/morty) with some suggestions from the issue tracker applied, named after [Yes, that Gap Youkai](https://en.touhouwiki.net/wiki/Yukari_Yakumo)
+Web content sanitizer proxy as a service, fork of [MortyProxy](https://github.com/asciimoo/morty) with some suggestions from the issue tracker applied, named after [the youkai you shouldn't ever come near](https://en.touhouwiki.net/wiki/Yukari_Yakumo)
 
-Yukari rewrites web pages to exclude malicious HTML tags and attributes. It also replaces external resource references to prevent third party information leaks.
+Yukari's Gap rewrites web pages to exclude malicious HTML tags and attributes. It also replaces external resource references to prevent third party information leaks.
 
-The main goal of yukari is to provide a result proxy for [searx](https://asciimoo.github.com/searx/), but it can be used as a standalone sanitizer service too.
+The main goal of this tool is to provide a result proxy for [searx](https://asciimoo.github.com/searx/), but it can be used as a standalone sanitizer service too.
 
 Features:
 
- - HTML sanitization
- - Rewrites HTML/CSS external references to locals
- - JavaScript blocking
- - No Cookies forwarded
- - No Referrers
- - No Caching/Etag
- - Supports GET/POST forms and IFrames
- - Optional HMAC URL verifier key to prevent service abuse
+* HTML sanitization
+* Rewrites HTML/CSS external references to locals
+* JavaScript blocking
+* No Cookies forwarded
+* No Referrers
+* No Caching/Etag
+* Supports GET/POST forms and IFrames
+* Optional HMAC URL verifier key to prevent service abuse
 
 
 ## Installation and setup
-Requirement: Go version 1.10 or higher.
+Requirement: Go version 1.16 or higher.
 
 ```
Index: /trunk/cmd/yukari/allowed_content_types.go
===================================================================
--- /trunk/cmd/yukari/allowed_content_types.go	(revision 147)
+++ /trunk/cmd/yukari/allowed_content_types.go	(revision 147)
@@ -0,0 +1,58 @@
+package main
+
+import (
+	"marisa.chaotic.ninja/yukari/contenttype"
+)
+
+var ALLOWED_CONTENTTYPE_FILTER contenttype.Filter = contenttype.NewFilterOr([]contenttype.Filter{
+        // html
+        contenttype.NewFilterEquals("text", "html", ""),
+        contenttype.NewFilterEquals("application", "xhtml", "xml"),
+        // css
+        contenttype.NewFilterEquals("text", "css", ""),
+        // images
+	contenttype.NewFilterEquals("image", "gif", ""),
+        contenttype.NewFilterEquals("image", "png", ""),
+        contenttype.NewFilterEquals("image", "jpeg", ""),
+        contenttype.NewFilterEquals("image", "pjpeg", ""),
+        contenttype.NewFilterEquals("image", "webp", ""),
+        contenttype.NewFilterEquals("image", "tiff", ""),
+        contenttype.NewFilterEquals("image", "vnd.microsoft.icon", ""),
+        contenttype.NewFilterEquals("image", "bmp", ""),
+        contenttype.NewFilterEquals("image", "x-ms-bmp", ""),
+        contenttype.NewFilterEquals("image", "x-icon", ""),
+        contenttype.NewFilterEquals("image", "svg", "xml"),
+        // fonts
+        contenttype.NewFilterEquals("application", "font-otf", ""),
+        contenttype.NewFilterEquals("application", "font-ttf", ""),
+        contenttype.NewFilterEquals("application", "font-woff", ""),
+        contenttype.NewFilterEquals("application", "vnd.ms-fontobject", ""),
+})
+
+var ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER contenttype.Filter = contenttype.NewFilterOr([]contenttype.Filter{
+        // texts
+        contenttype.NewFilterEquals("text", "csv", ""),
+        contenttype.NewFilterEquals("text", "tab-separated-values", ""),
+        contenttype.NewFilterEquals("text", "plain", ""),
+        // API
+        contenttype.NewFilterEquals("application", "json", ""),
+        // Documents
+        contenttype.NewFilterEquals("application", "x-latex", ""),
+        contenttype.NewFilterEquals("application", "pdf", ""),
+        contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.text", ""),
+        contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.spreadsheet", ""),
+        contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.presentation", ""),
+        contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.graphics", ""),
+        // Compressed archives
+        contenttype.NewFilterEquals("application", "zip", ""),
+        contenttype.NewFilterEquals("application", "gzip", ""),
+        contenttype.NewFilterEquals("application", "x-compressed", ""),
+        contenttype.NewFilterEquals("application", "x-gtar", ""),
+        contenttype.NewFilterEquals("application", "x-compress", ""),
+        // Generic binary
+        contenttype.NewFilterEquals("application", "octet-stream", ""),
+})
+
+var ALLOWED_CONTENTTYPE_PARAMETERS map[string]bool = map[string]bool{
+        "charset": true,
+}
Index: /trunk/cmd/yukari/link_http_equiv_safe_values.go
===================================================================
--- /trunk/cmd/yukari/link_http_equiv_safe_values.go	(revision 147)
+++ /trunk/cmd/yukari/link_http_equiv_safe_values.go	(revision 147)
@@ -0,0 +1,9 @@
+package main
+
+var LINK_HTTP_EQUIV_SAFE_VALUES [][]byte = [][]byte{
+        // X-UA-Compatible will be added automaticaly, so it can be skipped                                                                                           
+        []byte("date"),
+        []byte("last-modified"),
+        []byte("refresh"), // URL rewrite                                                                                                                             
+        []byte("content-language"),
+}
Index: /trunk/cmd/yukari/link_rel_safe_values.go
===================================================================
--- /trunk/cmd/yukari/link_rel_safe_values.go	(revision 147)
+++ /trunk/cmd/yukari/link_rel_safe_values.go	(revision 147)
@@ -0,0 +1,23 @@
+package main
+
+var LINK_REL_SAFE_VALUES [][]byte = [][]byte{
+        []byte("alternate"),
+        []byte("archives"),
+        []byte("author"),
+        []byte("copyright"),
+        []byte("first"),
+        []byte("help"),
+        []byte("icon"),
+        []byte("index"),
+        []byte("last"),
+        []byte("license"),
+        []byte("manifest"),
+        []byte("next"),
+        []byte("pingback"),
+        []byte("prev"),
+        []byte("publisher"),
+        []byte("search"),
+        []byte("shortcut icon"),
+        []byte("stylesheet"),
+        []byte("up"),
+}
Index: /trunk/cmd/yukari/main.go
===================================================================
--- /trunk/cmd/yukari/main.go	(revision 147)
+++ /trunk/cmd/yukari/main.go	(revision 147)
@@ -0,0 +1,979 @@
+package main
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	_ "embed"
+	"encoding/base64"
+	"encoding/hex"
+	"errors"
+	"flag"
+	"fmt"
+	"html/template"
+	"io"
+	"log"
+	"mime"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+	"unicode/utf8"
+
+	"github.com/valyala/fasthttp"
+	"github.com/valyala/fasthttp/fasthttpproxy"
+	"golang.org/x/net/html"
+	"golang.org/x/net/html/charset"
+	"golang.org/x/text/encoding"
+
+	"marisa.chaotic.ninja/yukari"
+	"marisa.chaotic.ninja/yukari/config"
+	"marisa.chaotic.ninja/yukari/contenttype"
+)
+
+const (
+	STATE_DEFAULT     int = 0
+	STATE_IN_STYLE    int = 1
+	STATE_IN_NOSCRIPT int = 2
+)
+
+const MAX_REDIRECT_COUNT = 5
+
+var CLIENT *fasthttp.Client = &fasthttp.Client{
+	MaxResponseBodySize: 10 * 1024 * 1024, // 10M
+	ReadBufferSize:      16 * 1024,        // 16K
+}
+
+var CSS_URL_REGEXP *regexp.Regexp = regexp.MustCompile("url\\((['\"]?)[ \\t\\f]*([\u0009\u0021\u0023-\u0026\u0028\u002a-\u007E]+)(['\"]?)\\)?")
+
+type Proxy struct {
+	Key            []byte
+	RequestTimeout time.Duration
+	FollowRedirect bool
+}
+
+type RequestConfig struct {
+	Key          []byte
+	BaseURL      *url.URL
+	BodyInjected bool
+}
+
+type HTMLBodyExtParam struct {
+	BaseURL     string
+	HasYukariKey bool
+	URLParamName string
+}
+
+type HTMLFormExtParam struct {
+	BaseURL   string
+	YukariHash string
+	URLParamName string
+	HashParamName string
+}
+type HTMLMainPageFormParam struct {
+	URLParamName string
+}
+
+var FAVICON_BYTES []byte
+var HTML_FORM_EXTENSION *template.Template
+var HTML_BODY_EXTENSION *template.Template
+var HTML_MAIN_PAGE_FORM *template.Template
+
+//go:embed templates/yukari_content_type.html
+var HTML_HEAD_CONTENT_TYPE string
+//go:embed templates/yukari_start.html
+var YUKARI_HTML_PAGE_START string
+//go:embed templates/yukari_stop.html
+var YUKARI_HTML_PAGE_END string
+
+func init() {
+	FaviconBase64 := "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII"
+
+	FAVICON_BYTES, _ = base64.StdEncoding.DecodeString(FaviconBase64)
+	var err error
+	HTML_FORM_EXTENSION, err = template.New("html_form_extension").Parse(
+		`<input type="hidden" name="yukariurl" value="{{.BaseURL}}" />{{if .YukariHash}}<input type="hidden" name="yukarihash" value="{{.YukariHash}}" />{{end}}`)
+	if err != nil {
+		panic(err)
+	}
+	HTML_BODY_EXTENSION, err = template.New("html_body_extension").Parse(`
+<input type="checkbox" id="yukaritoggle" autocomplete="off" />
+<div id="yukariheader">
+  <form method="get">
+    <label for="yukaritoggle">hide</label>
+    <span><a href="/">Yukari's Gap</a></span>
+    <input type="url" value="{{.BaseURL}}" name="{{.URLParamName}}" {{if .HasYukariKey }}readonly="true"{{end}} />
+    This is a proxified and sanitized view of the page, visit <a href="{{.BaseURL}}" rel="noreferrer">original site.
+  </form>
+</div>
+<style>
+body{ position: absolute !important; top: 42px !important; left: 0 !important; right: 0 !important; bottom: 0 !important; }
+#yukariheader { position: fixed; margin: 0; box-sizing: border-box; -webkit-box-sizing: border-box; top: 0; left: 0; right: 0; z-index: 2147483647 !important; font-size: 12px; line-height: normal; border-width: 0px 0px 2px 0; border-style: solid; border-color: #9826FF; background: #33004A; padding: 4px; color: #D881FF; height: 42px; }
+#yukariheader * { padding: 0; margin: 0; }
+#yukariheader p { padding: 0 0 0.7em 0; display: block; }
+#yukariheader a { color: #8934DB; font-weight: bold; display: inline; }
+#yukariheader label { text-align: right; cursor: pointer; position: fixed; right: 4px; top: 4px; display: block; color: #444; }
+#yukariheader > form > span { font-size: 24px; font-weight: bold; margin-right: 20px; margin-left: 20px; }
+input[type=checkbox]#yukaritoggle { display: none; }
+input[type=checkbox]#yukaritoggle:checked ~ div { display: none; visibility: hidden; }
+#yukariheader input[type=url] { width: 50%; padding: 4px; font-size: 16px; }
+</style>
+`)
+	if err != nil {
+		panic(err)
+	}
+	HTML_MAIN_PAGE_FORM, err = template.New("html_main_page_form").Parse(`
+	<form action="post">
+	Visit url: <input placeholder="https://url.." name="{{.URLParamName}}" autofocus />
+	<input type="submit" value="go" />
+	</form>`)
+	if err != nil {
+		panic(err)
+	}
+}
+
+func (p *Proxy) RequestHandler(ctx *fasthttp.RequestCtx) {
+
+	if appRequestHandler(ctx) {
+		return
+	}
+
+	requestHash := popRequestParam(ctx, []byte(config.Config.HashParameter))
+
+	requestURI := popRequestParam(ctx, []byte(config.Config.UrlParameter))
+
+	if requestURI == nil {
+		p.serveMainPage(ctx, 200, nil)
+		return
+	}
+
+	if p.Key != nil {
+		if !verifyRequestURI(requestURI, requestHash, p.Key) {
+			// HTTP status code 403 : Forbidden
+			error_message := fmt.Sprintf(`invalid "%s" parameter. hint: Hash URL Parameter`, config.Config.HashParameter)
+			p.serveMainPage(ctx, 403, errors.New(error_message))
+			return
+		}
+	}
+
+	requestURIQuery := ctx.QueryArgs().QueryString()
+	if len(requestURIQuery) > 0 {
+		if bytes.ContainsRune(requestURI, '?') {
+			requestURI = append(requestURI, '&')
+		} else {
+			requestURI = append(requestURI, '?')
+		}
+		requestURI = append(requestURI, requestURIQuery...)
+	}
+
+	p.ProcessUri(ctx, string(requestURI), 0)
+}
+
+func (p *Proxy) ProcessUri(ctx *fasthttp.RequestCtx, requestURIStr string, redirectCount int) {
+	parsedURI, err := url.Parse(requestURIStr)
+
+	if err != nil {
+		// HTTP status code 500 : Internal Server Error
+		p.serveMainPage(ctx, 500, err)
+		return
+	}
+
+	if parsedURI.Scheme == "" {
+		requestURIStr = "https://" + requestURIStr
+		parsedURI, err = url.Parse(requestURIStr)
+		if err != nil {
+			p.serveMainPage(ctx, 500, err)
+			return
+		}
+	}
+
+	// Serve an intermediate page for protocols other than HTTP(S)
+	if (parsedURI.Scheme != "http" && parsedURI.Scheme != "https") || strings.HasSuffix(parsedURI.Host, ".onion") || strings.HasSuffix(parsedURI.Host, ".i2p") {
+		p.serveExitYukariPage(ctx, parsedURI)
+		return
+	}
+
+	req := fasthttp.AcquireRequest()
+	defer fasthttp.ReleaseRequest(req)
+	req.SetConnectionClose()
+
+	if config.Config.Debug {
+		log.Println(string(ctx.Method()), requestURIStr)
+	}
+
+	req.SetRequestURI(requestURIStr)
+	req.Header.SetUserAgentBytes([]byte("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:112.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"))
+
+	resp := fasthttp.AcquireResponse()
+	defer fasthttp.ReleaseResponse(resp)
+
+	req.Header.SetMethodBytes(ctx.Method())
+	if ctx.IsPost() || ctx.IsPut() {
+		req.SetBody(ctx.PostBody())
+	}
+
+	err = CLIENT.DoTimeout(req, resp, p.RequestTimeout)
+
+	if err != nil {
+		if err == fasthttp.ErrTimeout {
+			// HTTP status code 504 : Gateway Time-Out
+			p.serveMainPage(ctx, 504, err)
+		} else {
+			// HTTP status code 500 : Internal Server Error
+			p.serveMainPage(ctx, 500, err)
+		}
+		return
+	}
+
+	if resp.StatusCode() != 200 {
+		switch resp.StatusCode() {
+		case 301, 302, 303, 307, 308:
+			loc := resp.Header.Peek("Location")
+			if loc != nil {
+				if p.FollowRedirect && ctx.IsGet() {
+					// GET method: Yukari follows the redirect
+					if redirectCount < MAX_REDIRECT_COUNT {
+						if config.Config.Debug {
+							log.Println("follow redirect to", string(loc))
+						}
+						p.ProcessUri(ctx, string(loc), redirectCount+1)
+					} else {
+						p.serveMainPage(ctx, 310, errors.New("Too many redirects"))
+					}
+					return
+				} else {
+					// Other HTTP methods: Yukari does NOT follow the redirect
+					rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI}
+					url, err := rc.ProxifyURI(loc)
+					if err == nil {
+						ctx.SetStatusCode(resp.StatusCode())
+						ctx.Response.Header.Add("Location", url)
+						if config.Config.Debug {
+							log.Println("redirect to", string(loc))
+						}
+						return
+					}
+				}
+			}
+		}
+		error_message := fmt.Sprintf("invalid response: %d (%s)", resp.StatusCode(), requestURIStr)
+		p.serveMainPage(ctx, resp.StatusCode(), errors.New(error_message))
+		return
+	}
+
+	contentTypeBytes := resp.Header.Peek("Content-Type")
+
+	if contentTypeBytes == nil {
+		// HTTP status code 503 : Service Unavailable
+		p.serveMainPage(ctx, 503, errors.New("invalid content type"))
+		return
+	}
+
+	contentTypeString := string(contentTypeBytes)
+
+	// decode Content-Type header
+	contentType, error := contenttype.ParseContentType(contentTypeString)
+	if error != nil {
+		// HTTP status code 503 : Service Unavailable
+		p.serveMainPage(ctx, 503, errors.New("invalid content type"))
+		return
+	}
+
+	// content-disposition
+	contentDispositionBytes := ctx.Request.Header.Peek("Content-Disposition")
+
+	// check content type
+	if !ALLOWED_CONTENTTYPE_FILTER(contentType) {
+		// it is not a usual content type
+		if ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER(contentType) {
+			// force attachment for allowed content type
+			contentDispositionBytes = contentDispositionForceAttachment(contentDispositionBytes, parsedURI)
+		} else {
+			// deny access to forbidden content type
+			// HTTP status code 403 : Forbidden
+			p.serveMainPage(ctx, 403, errors.New("forbidden content type "+parsedURI.String()))
+			return
+		}
+	}
+
+	// HACK : replace */xhtml by text/html
+	if contentType.SubType == "xhtml" {
+		contentType.TopLevelType = "text"
+		contentType.SubType = "html"
+		contentType.Suffix = ""
+	}
+
+	// conversion to UTF-8
+	var responseBody []byte
+
+	if contentType.TopLevelType == "text" {
+		e, ename, _ := charset.DetermineEncoding(resp.Body(), contentTypeString)
+		if (e != encoding.Nop) && (!strings.EqualFold("utf-8", ename)) {
+			responseBody, err = e.NewDecoder().Bytes(resp.Body())
+			if err != nil {
+				// HTTP status code 503 : Service Unavailable
+				p.serveMainPage(ctx, 503, err)
+				return
+			}
+		} else {
+			responseBody = resp.Body()
+		}
+		// update the charset or specify it
+		contentType.Parameters["charset"] = "UTF-8"
+	} else {
+		responseBody = resp.Body()
+	}
+
+	//
+	contentType.FilterParameters(ALLOWED_CONTENTTYPE_PARAMETERS)
+
+	// set the content type
+	ctx.SetContentType(contentType.String())
+
+	// output according to MIME type
+	switch {
+	case contentType.SubType == "css" && contentType.Suffix == "":
+		sanitizeCSS(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody)
+	case contentType.SubType == "html" && contentType.Suffix == "":
+		rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI}
+		sanitizeHTML(rc, ctx, responseBody)
+		if !rc.BodyInjected {
+			p := HTMLBodyExtParam{rc.BaseURL.String(), false, config.Config.UrlParameter}
+			if len(rc.Key) > 0 {
+				p.HasYukariKey = true
+			}
+			err := HTML_BODY_EXTENSION.Execute(ctx, p)
+			if err != nil {
+				if config.Config.Debug {
+					fmt.Println("failed to inject body extension", err)
+				}
+			}
+		}
+	default:
+		if contentDispositionBytes != nil {
+			ctx.Response.Header.AddBytesV("Content-Disposition", contentDispositionBytes)
+		}
+		ctx.Write(responseBody)
+	}
+}
+
+// force content-disposition to attachment
+func contentDispositionForceAttachment(contentDispositionBytes []byte, url *url.URL) []byte {
+	var contentDispositionParams map[string]string
+
+	if contentDispositionBytes != nil {
+		var err error
+		_, contentDispositionParams, err = mime.ParseMediaType(string(contentDispositionBytes))
+		if err != nil {
+			contentDispositionParams = make(map[string]string)
+		}
+	} else {
+		contentDispositionParams = make(map[string]string)
+	}
+
+	_, fileNameDefined := contentDispositionParams["filename"]
+	if !fileNameDefined {
+		// TODO : sanitize filename
+		contentDispositionParams["fileName"] = filepath.Base(url.Path)
+	}
+
+	return []byte(mime.FormatMediaType("attachment", contentDispositionParams))
+}
+
+func appRequestHandler(ctx *fasthttp.RequestCtx) bool {
+	// serve robots.txt
+	if bytes.Equal(ctx.Path(), []byte("/robots.txt")) {
+		ctx.SetContentType("text/plain")
+		ctx.Write([]byte("User-Agent: *\nDisallow: /\n"))
+		return true
+	}
+
+	// server favicon.ico
+	if bytes.Equal(ctx.Path(), []byte("/favicon.ico")) {
+		ctx.SetContentType("image/png")
+		ctx.Write(FAVICON_BYTES)
+		return true
+	}
+
+	return false
+}
+
+func popRequestParam(ctx *fasthttp.RequestCtx, paramName []byte) []byte {
+	param := ctx.QueryArgs().PeekBytes(paramName)
+
+	if param == nil {
+		param = ctx.PostArgs().PeekBytes(paramName)
+		ctx.PostArgs().DelBytes(paramName)
+	}
+	ctx.QueryArgs().DelBytes(paramName)
+
+	return param
+}
+
+func sanitizeCSS(rc *RequestConfig, out io.Writer, css []byte) {
+	// TODO
+
+	urlSlices := CSS_URL_REGEXP.FindAllSubmatchIndex(css, -1)
+
+	if urlSlices == nil {
+		out.Write(css)
+		return
+	}
+
+	startIndex := 0
+
+	for _, s := range urlSlices {
+		urlStart := s[4]
+		urlEnd := s[5]
+
+		if uri, err := rc.ProxifyURI(css[urlStart:urlEnd]); err == nil {
+			out.Write(css[startIndex:urlStart])
+			out.Write([]byte(uri))
+			startIndex = urlEnd
+		} else if config.Config.Debug {
+			log.Println("cannot proxify css uri:", string(css[urlStart:urlEnd]))
+		}
+	}
+	if startIndex < len(css) {
+		out.Write(css[startIndex:len(css)])
+	}
+}
+
+func sanitizeHTML(rc *RequestConfig, out io.Writer, htmlDoc []byte) {
+	r := bytes.NewReader(htmlDoc)
+	decoder := html.NewTokenizer(r)
+	decoder.AllowCDATA(true)
+
+	unsafeElements := make([][]byte, 0, 8)
+	state := STATE_DEFAULT
+	for {
+		token := decoder.Next()
+		if token == html.ErrorToken {
+			err := decoder.Err()
+			if err != io.EOF {
+				log.Println("failed to parse HTML")
+			}
+			break
+		}
+
+		if len(unsafeElements) == 0 {
+
+			switch token {
+			case html.StartTagToken, html.SelfClosingTagToken:
+				tag, hasAttrs := decoder.TagName()
+				safe := !inArray(tag, UNSAFE_ELEMENTS)
+				if !safe {
+					if token != html.SelfClosingTagToken {
+						var unsafeTag []byte = make([]byte, len(tag))
+						copy(unsafeTag, tag)
+						unsafeElements = append(unsafeElements, unsafeTag)
+					}
+					break
+				}
+				if bytes.Equal(tag, []byte("base")) {
+					for {
+						attrName, attrValue, moreAttr := decoder.TagAttr()
+						if bytes.Equal(attrName, []byte("href")) {
+							parsedURI, err := url.Parse(string(attrValue))
+							if err == nil {
+								rc.BaseURL = parsedURI
+							}
+						}
+						if !moreAttr {
+							break
+						}
+					}
+					break
+				}
+				if bytes.Equal(tag, []byte("noscript")) {
+					state = STATE_IN_NOSCRIPT
+					break
+				}
+				var attrs [][][]byte
+				if hasAttrs {
+					for {
+						attrName, attrValue, moreAttr := decoder.TagAttr()
+						attrs = append(attrs, [][]byte{
+							attrName,
+							attrValue,
+							[]byte(html.EscapeString(string(attrValue))),
+						})
+						if !moreAttr {
+							break
+						}
+					}
+				}
+				if bytes.Equal(tag, []byte("link")) {
+					sanitizeLinkTag(rc, out, attrs)
+					break
+				}
+
+				if bytes.Equal(tag, []byte("meta")) {
+					sanitizeMetaTag(rc, out, attrs)
+					break
+				}
+
+				fmt.Fprintf(out, "<%s", tag)
+
+				if hasAttrs {
+					sanitizeAttrs(rc, out, attrs)
+				}
+
+				if token == html.SelfClosingTagToken {
+					fmt.Fprintf(out, " />")
+				} else {
+					fmt.Fprintf(out, ">")
+					if bytes.Equal(tag, []byte("style")) {
+						state = STATE_IN_STYLE
+					}
+				}
+
+				if bytes.Equal(tag, []byte("head")) {
+					fmt.Fprintf(out, HTML_HEAD_CONTENT_TYPE)
+				}
+
+				if bytes.Equal(tag, []byte("form")) {
+					var formURL *url.URL
+					for _, attr := range attrs {
+						if bytes.Equal(attr[0], []byte("action")) {
+							formURL, _ = url.Parse(string(attr[1]))
+							formURL = mergeURIs(rc.BaseURL, formURL)
+							break
+						}
+					}
+					if formURL == nil {
+						formURL = rc.BaseURL
+					}
+					urlStr := formURL.String()
+					var key string
+					if rc.Key != nil {
+						key = hash(urlStr, rc.Key)
+					}
+					err := HTML_FORM_EXTENSION.Execute(out, HTMLFormExtParam{urlStr, key, config.Config.UrlParameter, config.Config.HashParameter})
+					if err != nil {
+						if config.Config.Debug {
+							fmt.Println("failed to inject body extension", err)
+						}
+					}
+				}
+
+			case html.EndTagToken:
+				tag, _ := decoder.TagName()
+				writeEndTag := true
+				switch string(tag) {
+				case "body":
+					p := HTMLBodyExtParam{rc.BaseURL.String(), false, config.Config.UrlParameter}
+					if len(rc.Key) > 0 {
+						p.HasYukariKey = true
+					}
+					err := HTML_BODY_EXTENSION.Execute(out, p)
+					if err != nil {
+						if config.Config.Debug {
+							fmt.Println("failed to inject body extension", err)
+						}
+					}
+					rc.BodyInjected = true
+				case "style":
+					state = STATE_DEFAULT
+				case "noscript":
+					state = STATE_DEFAULT
+					writeEndTag = false
+				}
+				// skip noscript tags - only the tag, not the content, because javascript is sanitized
+				if writeEndTag {
+					fmt.Fprintf(out, "</%s>", tag)
+				}
+
+			case html.TextToken:
+				switch state {
+				case STATE_DEFAULT:
+					fmt.Fprintf(out, "%s", decoder.Raw())
+				case STATE_IN_STYLE:
+					sanitizeCSS(rc, out, decoder.Raw())
+				case STATE_IN_NOSCRIPT:
+					sanitizeHTML(rc, out, decoder.Raw())
+				}
+
+			case html.CommentToken:
+				// ignore comment. TODO : parse IE conditional comment
+
+			case html.DoctypeToken:
+				out.Write(decoder.Raw())
+			}
+		} else {
+			switch token {
+			case html.StartTagToken, html.SelfClosingTagToken:
+				tag, _ := decoder.TagName()
+				if inArray(tag, UNSAFE_ELEMENTS) {
+					unsafeElements = append(unsafeElements, tag)
+				}
+
+			case html.EndTagToken:
+				tag, _ := decoder.TagName()
+				if bytes.Equal(unsafeElements[len(unsafeElements)-1], tag) {
+					unsafeElements = unsafeElements[:len(unsafeElements)-1]
+				}
+			}
+		}
+	}
+}
+
+func sanitizeLinkTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
+	exclude := false
+	for _, attr := range attrs {
+		attrName := attr[0]
+		attrValue := attr[1]
+		if bytes.Equal(attrName, []byte("rel")) {
+			if !inArray(attrValue, LINK_REL_SAFE_VALUES) {
+				exclude = true
+				break
+			}
+		}
+		if bytes.Equal(attrName, []byte("as")) {
+			if bytes.Equal(attrValue, []byte("script")) {
+				exclude = true
+				break
+			}
+		}
+	}
+
+	if !exclude {
+		out.Write([]byte("<link"))
+		for _, attr := range attrs {
+			sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
+		}
+		out.Write([]byte(">"))
+	}
+}
+
+func sanitizeMetaTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
+	var http_equiv []byte
+	var content []byte
+
+	for _, attr := range attrs {
+		attrName := attr[0]
+		attrValue := attr[1]
+		if bytes.Equal(attrName, []byte("http-equiv")) {
+			http_equiv = bytes.ToLower(attrValue)
+			// exclude some <meta http-equiv="..." ..>
+			if !inArray(http_equiv, LINK_HTTP_EQUIV_SAFE_VALUES) {
+				return
+			}
+		}
+		if bytes.Equal(attrName, []byte("content")) {
+			content = attrValue
+		}
+		if bytes.Equal(attrName, []byte("charset")) {
+			// exclude <meta charset="...">
+			return
+		}
+	}
+
+	out.Write([]byte("<meta"))
+	urlIndex := bytes.Index(bytes.ToLower(content), []byte("url="))
+	if bytes.Equal(http_equiv, []byte("refresh")) && urlIndex != -1 {
+		contentUrl := content[urlIndex+4:]
+		// special case of <meta http-equiv="refresh" content="0; url='example.com/url.with.quote.outside'">
+		if len(contentUrl) >= 2 && (contentUrl[0] == byte('\'') || contentUrl[0] == byte('"')) {
+			if contentUrl[0] == contentUrl[len(contentUrl)-1] {
+				contentUrl = contentUrl[1 : len(contentUrl)-1]
+			}
+		}
+		// output proxify result
+		if uri, err := rc.ProxifyURI(contentUrl); err == nil {
+			fmt.Fprintf(out, ` http-equiv="refresh" content="%surl=%s"`, content[:urlIndex], uri)
+		}
+	} else {
+		if len(http_equiv) > 0 {
+			fmt.Fprintf(out, ` http-equiv="%s"`, http_equiv)
+		}
+		sanitizeAttrs(rc, out, attrs)
+	}
+	out.Write([]byte(">"))
+}
+
+func sanitizeAttrs(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
+	for _, attr := range attrs {
+		sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
+	}
+}
+
+func sanitizeAttr(rc *RequestConfig, out io.Writer, attrName, attrValue, escapedAttrValue []byte) {
+	if inArray(attrName, SAFE_ATTRIBUTES) {
+		fmt.Fprintf(out, " %s=\"%s\"", attrName, escapedAttrValue)
+		return
+	}
+	switch string(attrName) {
+	case "src", "href", "action":
+		if uri, err := rc.ProxifyURI(attrValue); err == nil {
+			fmt.Fprintf(out, " %s=\"%s\"", attrName, uri)
+		} else if config.Config.Debug {
+			log.Println("cannot proxify uri:", string(attrValue))
+		}
+	case "style":
+		cssAttr := bytes.NewBuffer(nil)
+		sanitizeCSS(rc, cssAttr, attrValue)
+		fmt.Fprintf(out, " %s=\"%s\"", attrName, html.EscapeString(string(cssAttr.Bytes())))
+	}
+}
+
+func mergeURIs(u1, u2 *url.URL) *url.URL {
+	if u2 == nil {
+		return u1
+	}
+	return u1.ResolveReference(u2)
+}
+
+// Sanitized URI : removes all runes bellow 32 (included) as the begining and end of URI, and lower case the scheme.
+// avoid memory allocation (except for the scheme)
+func sanitizeURI(uri []byte) ([]byte, string) {
+	first_rune_index := 0
+	first_rune_seen := false
+	scheme_last_index := -1
+	buffer := bytes.NewBuffer(make([]byte, 0, 10))
+
+	// remove trailing space and special characters
+	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")
+
+	// loop over byte by byte
+	for i, c := range uri {
+		// ignore special characters and space (c <= 32)
+		if c > 32 {
+			// append to the lower case of the rune to buffer
+			if c < utf8.RuneSelf && 'A' <= c && c <= 'Z' {
+				c = c + 'a' - 'A'
+			}
+
+			buffer.WriteByte(c)
+
+			// update the first rune index that is not a special rune
+			if !first_rune_seen {
+				first_rune_index = i
+				first_rune_seen = true
+			}
+
+			if c == ':' {
+				// colon rune found, we have found the scheme
+				scheme_last_index = i
+				break
+			} else if c == '/' || c == '?' || c == '\\' || c == '#' {
+				// special case : most probably a relative URI
+				break
+			}
+		}
+	}
+
+	if scheme_last_index != -1 {
+		// scheme found
+		// copy the "lower case without special runes scheme" before the ":" rune
+		scheme_start_index := scheme_last_index - buffer.Len() + 1
+		copy(uri[scheme_start_index:], buffer.Bytes())
+		// and return the result
+		return uri[scheme_start_index:], buffer.String()
+	} else {
+		// scheme NOT found
+		return uri[first_rune_index:], ""
+	}
+}
+
+func (rc *RequestConfig) ProxifyURI(uri []byte) (string, error) {
+	// sanitize URI
+	uri, scheme := sanitizeURI(uri)
+
+	// remove javascript protocol
+	if scheme == "javascript:" {
+		return "", nil
+	}
+
+	// TODO check malicious data: - e.g. data:script
+	if scheme == "data:" {
+		if bytes.HasPrefix(uri, []byte("data:image/png")) ||
+			bytes.HasPrefix(uri, []byte("data:image/jpeg")) ||
+			bytes.HasPrefix(uri, []byte("data:image/pjpeg")) ||
+			bytes.HasPrefix(uri, []byte("data:image/gif")) ||
+			bytes.HasPrefix(uri, []byte("data:image/webp")) {
+			// should be safe
+			return string(uri), nil
+		} else {
+			// unsafe data
+			return "", nil
+		}
+	}
+
+	// parse the uri
+	u, err := url.Parse(string(uri))
+	if err != nil {
+		return "", err
+	}
+
+	// get the fragment (with the prefix "#")
+	fragment := ""
+	if len(u.Fragment) > 0 {
+		fragment = "#" + u.Fragment
+	}
+
+	// reset the fragment: it is not included in the yukariurl
+	u.Fragment = ""
+
+	// merge the URI with the document URI
+	u = mergeURIs(rc.BaseURL, u)
+
+	// simple internal link ?
+	// some web pages describe the whole link https://same:auth@same.host/same.path?same.query#new.fragment
+	if u.Scheme == rc.BaseURL.Scheme &&
+		(rc.BaseURL.User == nil || (u.User != nil && u.User.String() == rc.BaseURL.User.String())) &&
+		u.Host == rc.BaseURL.Host &&
+		u.Path == rc.BaseURL.Path &&
+		u.RawQuery == rc.BaseURL.RawQuery {
+		// the fragment is the only difference between the document URI and the uri parameter
+		return fragment, nil
+	}
+
+	// return full URI and fragment (if not empty)
+	yukari_uri := u.String()
+
+	if rc.Key == nil {
+		return fmt.Sprintf("./?%s=%s%s", config.Config.UrlParameter, url.QueryEscape(yukari_uri), fragment), nil
+	}
+	return fmt.Sprintf("./?%s=%s&%s=%s%s", config.Config.HashParameter, hash(yukari_uri, rc.Key), config.Config.UrlParameter, url.QueryEscape(yukari_uri), fragment), nil
+}
+
+func inArray(b []byte, a [][]byte) bool {
+	for _, b2 := range a {
+		if bytes.Equal(b, b2) {
+			return true
+		}
+	}
+	return false
+}
+
+func hash(msg string, key []byte) string {
+	mac := hmac.New(sha256.New, key)
+	mac.Write([]byte(msg))
+	return hex.EncodeToString(mac.Sum(nil))
+}
+
+func verifyRequestURI(uri, hashMsg, key []byte) bool {
+	h := make([]byte, hex.DecodedLen(len(hashMsg)))
+	_, err := hex.Decode(h, hashMsg)
+	if err != nil {
+		if config.Config.Debug {
+			log.Println("hmac error:", err)
+		}
+		return false
+	}
+	mac := hmac.New(sha256.New, key)
+	mac.Write(uri)
+	return hmac.Equal(h, mac.Sum(nil))
+}
+
+func (p *Proxy) serveExitYukariPage(ctx *fasthttp.RequestCtx, uri *url.URL) {
+	ctx.SetContentType("text/html")
+	ctx.SetStatusCode(403)
+	ctx.Write([]byte(YUKARI_HTML_PAGE_START))
+	ctx.Write([]byte("<h2>You are about to exit YukariSukima</h2>"))
+	ctx.Write([]byte("<p>Following</p><p><a href=\""))
+	ctx.Write([]byte(html.EscapeString(uri.String())))
+	ctx.Write([]byte("\" rel=\"noreferrer\">"))
+	ctx.Write([]byte(html.EscapeString(uri.String())))
+	ctx.Write([]byte("</a></p><p>the content of this URL will be <b>NOT</b> sanitized.</p>"))
+	ctx.Write([]byte(YUKARI_HTML_PAGE_END))
+}
+
+func (p *Proxy) serveMainPage(ctx *fasthttp.RequestCtx, statusCode int, err error) {
+	ctx.SetContentType("text/html; charset=UTF-8")
+	ctx.SetStatusCode(statusCode)
+	ctx.Write([]byte(YUKARI_HTML_PAGE_START))
+	if err != nil {
+		if config.Config.Debug {
+			log.Println("error:", err)
+		}
+		ctx.Write([]byte("<h2>Error: "))
+		ctx.Write([]byte(html.EscapeString(err.Error())))
+		ctx.Write([]byte("</h2>"))
+	}
+	if p.Key == nil {
+		p := HTMLMainPageFormParam{config.Config.UrlParameter}
+		err := HTML_MAIN_PAGE_FORM.Execute(ctx, p)
+		if err != nil {
+			if config.Config.Debug {
+				fmt.Println("failed to inject main page form", err)
+			}
+		}
+	} else {
+		ctx.Write([]byte(`<h3>Warning! This instance does not support direct URL opening.</h3>`))
+	}
+	ctx.Write([]byte(YUKARI_HTML_PAGE_END))
+}
+
+func main() {
+	var configFile string
+	var proxy string
+	var proxyEnv bool
+	var socks5 string
+	var version bool
+
+	flag.StringVar(&configFile, "f", "", "Configuration file")
+	flag.BoolVar(&proxyEnv, "proxyenv", false, "Use a HTTP proxy as set in the environment (HTTP_PROXY, HTTPS_PROXY and NO_PROXY). Overrides: -proxy, -socks5, IPv6")
+	flag.StringVar(&proxy, "proxy", "", "Use the specified HTTP proxy (ie: '[user:pass@]hostname:port'). Overrides: -socks5, IPv6")
+	flag.StringVar(&socks5, "socks5", "", "Use a SOCKS5 proxy (ie: 'hostname:port'). Overrides: IPv6.")
+	flag.BoolVar(&version, "version", false, "Show version")
+	flag.Parse()
+
+	config.Config.ListenAddress = "127.0.0.1:3000"
+	config.Config.Key = ""
+	config.Config.IPV6 = true
+	config.Config.Debug = false
+	config.Config.RequestTimeout = 5
+	config.Config.FollowRedirect = false
+	config.Config.UrlParameter = "yukariurl"
+	config.Config.HashParameter = "yukarihash"
+	config.Config.MaxConnsPerHost = 4
+
+	if version {
+		fmt.Println(yukari.FullVersion())
+		return
+	}
+
+	if proxyEnv && os.Getenv("HTTP_PROXY") == "" && os.Getenv("HTTPS_PROXY") == "" {
+		log.Fatal("Error -proxyenv is used but no environment variables named 'HTTP_PROXY' and/or 'HTTPS_PROXY' could be found.")
+		os.Exit(1)
+	}
+
+	if proxyEnv {
+		CLIENT.Dial = fasthttpproxy.FasthttpProxyHTTPDialer()
+		log.Println("Using environment defined proxy(ies).")
+	} else if proxy != "" {
+		CLIENT.Dial = fasthttpproxy.FasthttpHTTPDialer(proxy)
+		log.Println("Using custom HTTP proxy.")
+	} else if socks5 != "" {
+		CLIENT.Dial = fasthttpproxy.FasthttpSocksDialer(socks5)
+		log.Println("Using Socks5 proxy.")
+	} else if config.Config.IPV6 {
+		CLIENT.Dial = fasthttp.DialDualStack
+		log.Println("Using dual stack (IPv4/IPv6) direct connections.")
+	} else {
+		CLIENT.Dial = fasthttp.Dial
+		log.Println("Using IPv4 only direct connections.")
+	}
+
+	p := &Proxy{RequestTimeout: time.Duration(config.Config.RequestTimeout) * time.Second,
+		FollowRedirect: config.Config.FollowRedirect}
+
+	if config.Config.Key != "" {
+		var err error
+		p.Key, err = base64.StdEncoding.DecodeString(config.Config.Key)
+		if err != nil {
+			log.Fatal("Error parsing -key", err.Error())
+			os.Exit(1)
+		}
+	}
+	log.Println("ゆかり様、お願いします…！")
+	log.Println("Listening on", config.Config.ListenAddress)
+
+	if err := fasthttp.ListenAndServe(config.Config.ListenAddress, p.RequestHandler); err != nil {
+		log.Fatal("Error in ListenAndServe:", err)
+	}
+}
Index: /trunk/cmd/yukari/main_test.go
===================================================================
--- /trunk/cmd/yukari/main_test.go	(revision 147)
+++ /trunk/cmd/yukari/main_test.go	(revision 147)
@@ -0,0 +1,227 @@
+package main
+
+import (
+	"bytes"
+	"net/url"
+	"testing"
+)
+
+type AttrTestCase struct {
+	AttrName       []byte
+	AttrValue      []byte
+	ExpectedOutput []byte
+}
+
+type SanitizeURITestCase struct {
+	Input          []byte
+	ExpectedOutput []byte
+	ExpectedScheme string
+}
+
+type StringTestCase struct {
+	Input          string
+	ExpectedOutput string
+}
+
+var attrTestData []*AttrTestCase = []*AttrTestCase{
+	&AttrTestCase{
+		[]byte("href"),
+		[]byte("./x"),
+		[]byte(` href="./?yukariurl=http%3A%2F%2F127.0.0.1%2Fx"`),
+	},
+	&AttrTestCase{
+		[]byte("src"),
+		[]byte("http://x.com/y"),
+		[]byte(` src="./?yukariurl=http%3A%2F%2Fx.com%2Fy"`),
+	},
+	&AttrTestCase{
+		[]byte("action"),
+		[]byte("/z"),
+		[]byte(` action="./?yukariurl=http%3A%2F%2F127.0.0.1%2Fz"`),
+	},
+	&AttrTestCase{
+		[]byte("onclick"),
+		[]byte("console.log(document.cookies)"),
+		nil,
+	},
+}
+
+var sanitizeUriTestData []*SanitizeURITestCase = []*SanitizeURITestCase{
+	&SanitizeURITestCase{
+		[]byte("http://example.com/"),
+		[]byte("http://example.com/"),
+		"http:",
+	},
+	&SanitizeURITestCase{
+		[]byte("HtTPs://example.com/     \t"),
+		[]byte("https://example.com/"),
+		"https:",
+	},
+	&SanitizeURITestCase{
+		[]byte("      Ht  TPs://example.com/     \t"),
+		[]byte("https://example.com/"),
+		"https:",
+	},
+	&SanitizeURITestCase{
+		[]byte("javascript:void(0)"),
+		[]byte("javascript:void(0)"),
+		"javascript:",
+	},
+	&SanitizeURITestCase{
+		[]byte("      /path/to/a/file/without/protocol     "),
+		[]byte("/path/to/a/file/without/protocol"),
+		"",
+	},
+	&SanitizeURITestCase{
+		[]byte("      #fragment     "),
+		[]byte("#fragment"),
+		"",
+	},
+	&SanitizeURITestCase{
+		[]byte("      qwertyuiop     "),
+		[]byte("qwertyuiop"),
+		"",
+	},
+	&SanitizeURITestCase{
+		[]byte(""),
+		[]byte(""),
+		"",
+	},
+	&SanitizeURITestCase{
+		[]byte(":"),
+		[]byte(":"),
+		":",
+	},
+	&SanitizeURITestCase{
+		[]byte("   :"),
+		[]byte(":"),
+		":",
+	},
+	&SanitizeURITestCase{
+		[]byte("schéma:"),
+		[]byte("schéma:"),
+		"schéma:",
+	},
+}
+
+var urlTestData []*StringTestCase = []*StringTestCase{
+	&StringTestCase{
+		"http://x.com/",
+		"./?yukariurl=http%3A%2F%2Fx.com%2F",
+	},
+	&StringTestCase{
+		"http://a@x.com/",
+		"./?yukariurl=http%3A%2F%2Fa%40x.com%2F",
+	},
+	&StringTestCase{
+		"#a",
+		"#a",
+	},
+}
+
+func TestAttrSanitizer(t *testing.T) {
+	u, _ := url.Parse("http://127.0.0.1/")
+	rc := &RequestConfig{BaseURL: u}
+	for _, testCase := range attrTestData {
+		out := bytes.NewBuffer(nil)
+		sanitizeAttr(rc, out, testCase.AttrName, testCase.AttrValue, testCase.AttrValue)
+		res, _ := out.ReadBytes(byte(0))
+		if !bytes.Equal(res, testCase.ExpectedOutput) {
+			t.Errorf(
+				`Attribute parse error. Name: "%s", Value: "%s", Expected: %s, Got: "%s"`,
+				testCase.AttrName,
+				testCase.AttrValue,
+				testCase.ExpectedOutput,
+				res,
+			)
+		}
+	}
+}
+
+func TestSanitizeURI(t *testing.T) {
+	for _, testCase := range sanitizeUriTestData {
+		newUrl, scheme := sanitizeURI(testCase.Input)
+		if !bytes.Equal(newUrl, testCase.ExpectedOutput) {
+			t.Errorf(
+				`URL proxifier error. Expected: "%s", Got: "%s"`,
+				testCase.ExpectedOutput,
+				newUrl,
+			)
+		}
+		if scheme != testCase.ExpectedScheme {
+			t.Errorf(
+				`URL proxifier error. Expected: "%s", Got: "%s"`,
+				testCase.ExpectedScheme,
+				scheme,
+			)
+		}
+	}
+}
+
+func TestURLProxifier(t *testing.T) {
+	u, _ := url.Parse("http://127.0.0.1/")
+	rc := &RequestConfig{BaseURL: u}
+	for _, testCase := range urlTestData {
+		newUrl, err := rc.ProxifyURI([]byte(testCase.Input))
+		if err != nil {
+			t.Errorf("Failed to parse URL: %s", testCase.Input)
+		}
+		if newUrl != testCase.ExpectedOutput {
+			t.Errorf(
+				`URL proxifier error. Expected: "%s", Got: "%s"`,
+				testCase.ExpectedOutput,
+				newUrl,
+			)
+		}
+	}
+}
+
+var BENCH_SIMPLE_HTML []byte = []byte(`<!doctype html>
+<html>
+ <head>
+  <title>test</title>
+ </head>
+ <body>
+  <h1>Test heading</h1>
+ </body>
+</html>`)
+
+func BenchmarkSanitizeSimpleHTML(b *testing.B) {
+	u, _ := url.Parse("http://127.0.0.1/")
+	rc := &RequestConfig{BaseURL: u}
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		out := bytes.NewBuffer(nil)
+		sanitizeHTML(rc, out, BENCH_SIMPLE_HTML)
+	}
+}
+
+var BENCH_COMPLEX_HTML []byte = []byte(`<!doctype html>
+<html>
+ <head>
+  <noscript><meta http-equiv="refresh" content="0; URL=./xy"></noscript>
+  <title>test 2</title>
+  <script> alert('xy'); </script>
+  <link rel="stylesheet" href="./core.bundle.css">
+  <style>
+   html { background: url(./a.jpg); }
+  </style
+ </head>
+ <body>
+  <h1>Test heading</h1>
+  <img src="b.png" alt="imgtitle" />
+  <form action="/z">
+  <input type="submit" style="background: url(http://aa.bb/cc)" >
+  </form>
+ </body>
+</html>`)
+
+func BenchmarkSanitizeComplexHTML(b *testing.B) {
+	u, _ := url.Parse("http://127.0.0.1/")
+	rc := &RequestConfig{BaseURL: u}
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		out := bytes.NewBuffer(nil)
+		sanitizeHTML(rc, out, BENCH_COMPLEX_HTML)
+	}
+}
Index: /trunk/cmd/yukari/safe_attributes.go
===================================================================
--- /trunk/cmd/yukari/safe_attributes.go	(revision 147)
+++ /trunk/cmd/yukari/safe_attributes.go	(revision 147)
@@ -0,0 +1,38 @@
+package main
+
+var SAFE_ATTRIBUTES [][]byte = [][]byte{
+	[]byte("abbr"),
+	[]byte("accesskey"),
+	[]byte("align"),
+	[]byte("alt"),
+	[]byte("as"),
+	[]byte("autocomplete"),
+	[]byte("charset"),
+	[]byte("checked"),
+	[]byte("class"),
+	[]byte("content"),
+	[]byte("contenteditable"),
+	[]byte("contextmenu"),
+	[]byte("dir"),
+	[]byte("for"),
+	[]byte("height"),
+	[]byte("hidden"),
+	[]byte("hreflang"),
+	[]byte("id"),
+	[]byte("lang"),
+	[]byte("media"),
+	[]byte("method"),
+	[]byte("name"),
+	[]byte("nowrap"),
+	[]byte("placeholder"),
+	[]byte("property"),
+	[]byte("rel"),
+	[]byte("spellcheck"),
+	[]byte("tabindex"),
+	[]byte("target"),
+	[]byte("title"),
+	[]byte("translate"),
+	[]byte("type"),
+	[]byte("value"),
+	[]byte("width"),
+}
Index: /trunk/cmd/yukari/templates/yukari_content_type.html
===================================================================
--- /trunk/cmd/yukari/templates/yukari_content_type.html	(revision 147)
+++ /trunk/cmd/yukari/templates/yukari_content_type.html	(revision 147)
@@ -0,0 +1,3 @@
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="referrer" content="no-referrer">
Index: /trunk/cmd/yukari/templates/yukari_start.html
===================================================================
--- /trunk/cmd/yukari/templates/yukari_start.html	(revision 147)
+++ /trunk/cmd/yukari/templates/yukari_start.html	(revision 147)
@@ -0,0 +1,60 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+  <head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=1">
+    <style>
+      html {
+	  height: 100%;
+      }
+      body {
+	  min-height: 100%;
+	  display: flex;
+	  flex-direction: column;
+	  font-family: sans-serif;
+	  text-align: center;
+	  color: #BC48FC;
+	  background: #240039;
+	  margin: 0;
+	  padding: 0;
+	  font-size: 1.1em;
+      }
+      input {
+	  border: 1px solid #888;
+	  padding: 0.3em;
+	  color: #BC48FC;
+	  background: #202020;
+	  font-size: 1.1.em;
+      }
+      input[placeholder] {
+	  width: 80%;
+      }
+      a {
+	  text-decoration: none;
+	  color: #9529B9;
+      }
+      h1, h2 {
+	  font-weight: 200;
+	  margin-bottom: 2rem;
+      }
+      h1 {
+	  font-size: 3em;
+      }
+      .container {
+	  flex: 1;
+	  min-height: 100%;
+	  margin-bottom: 1em;
+      }
+      .footer {
+	  margin: 1em;
+      }
+      .footer p {
+	  font-size: 0.8em;
+      }
+      </style>
+    <title>Yukari's Gap</title>
+  </head>
+  <body>
+    <div class="container">
+      <h1>Yukari's Gap</h1>
+      
Index: /trunk/cmd/yukari/templates/yukari_stop.html
===================================================================
--- /trunk/cmd/yukari/templates/yukari_stop.html	(revision 147)
+++ /trunk/cmd/yukari/templates/yukari_stop.html	(revision 147)
@@ -0,0 +1,10 @@
+</div>
+<div class="footer">
+  <p>
+    Yukari's Gap rewrites web pages to exclude malicious HTML tags and CSS/HTML attributes. <br>
+    It also replaces external resource references to prevent third-party information leaks. <br>
+    <a href="https://git.chaotic.ninja/usr/yakumo_izuru/yukari/">View on UnreliableGit</a> &bull; a <a href="https://mirage.h0stname.net">Mirage AIB</a> project
+  </p>
+</div>
+</body>
+</html>
Index: /trunk/cmd/yukari/unsafe_elements.go
===================================================================
--- /trunk/cmd/yukari/unsafe_elements.go	(revision 147)
+++ /trunk/cmd/yukari/unsafe_elements.go	(revision 147)
@@ -0,0 +1,10 @@
+package main
+
+var UNSAFE_ELEMENTS [][]byte = [][]byte{
+	[]byte("applet"),
+	[]byte("canvas"),
+	[]byte("embed"),
+	[]byte("math"),
+	[]byte("script"),
+	[]byte("svg"),
+}
Index: /trunk/config/config.go
===================================================================
--- /trunk/config/config.go	(revision 146)
+++ /trunk/config/config.go	(revision 147)
@@ -2,8 +2,8 @@
 
 import (
-	"os"
+	"gopkg.in/ini.v1"
 )
 
-type Config struct {
+var Config struct {
 	Debug          bool
 	ListenAddress  string
@@ -17,30 +17,19 @@
 }
 
-var DefaultConfig *Config
+func readConfig(file string) error {
+	cfg, err := ini.Load(file)
+	if err != nil {
+		return err
+	}
+	Config.Debug, _ = cfg.Section("yukari").Key("debug").Bool()
+	Config.ListenAddress = cfg.Section("yukari").Key("listen").String()
+	Config.Key = cfg.Section("yukari").Key("key").String()
+	Config.IPV6, _ = cfg.Section("yukari").Key("ipv6").Bool()
+	Config.RequestTimeout, _ = cfg.Section("yukari").Key("timeout").Uint()
+	Config.FollowRedirect, _ = cfg.Section("yukari").Key("followredirect").Bool()
+	Config.MaxConnsPerHost, _ = cfg.Section("yukari").Key("max_conns_per_host").Uint()
+	Config.UrlParameter = cfg.Section("yukari").Key("urlparam").String()
+	Config.HashParameter = cfg.Section("yukari").Key("hashparam").String()
 
-func init() {
-	default_listen_addr := os.Getenv("YUKARI_ADDRESS")
-	if default_listen_addr == "" {
-		default_listen_addr = "127.0.0.1:3000"
-	}
-	default_url_parameter := os.Getenv("YUKARI_URL_PARAM")
-	if default_url_parameter == "" {
-		default_url_parameter = "yukariurl"
-	}
-	default_hash_parameter := os.Getenv("YUKARI_HASH_PARAM")
-	if default_hash_parameter == "" {
-		default_hash_parameter = "yukarihash"
-	}
-	default_key := os.Getenv("YUKARI_KEY")
-	DefaultConfig = &Config{
-		Debug:          os.Getenv("DEBUG") != "false",
-		ListenAddress:  default_listen_addr,
-		Key:            default_key,
-		IPV6:           true,
-		RequestTimeout: 5,
-		FollowRedirect: false,
-		MaxConnsPerHost: 4,
-		UrlParameter: default_url_parameter,
-		HashParameter: default_hash_parameter,
-	}
+	return nil
 }
Index: /trunk/go.mod
===================================================================
--- /trunk/go.mod	(revision 146)
+++ /trunk/go.mod	(revision 147)
@@ -1,9 +1,11 @@
 module marisa.chaotic.ninja/yukari
 
-go 1.14
+go 1.16
 
 require (
+	github.com/stretchr/testify v1.9.0 // indirect
 	github.com/valyala/fasthttp v1.34.0
 	golang.org/x/net v0.7.0
 	golang.org/x/text v0.7.0
+	gopkg.in/ini.v1 v1.67.0
 )
Index: /trunk/go.sum
===================================================================
--- /trunk/go.sum	(revision 146)
+++ /trunk/go.sum	(revision 147)
@@ -1,6 +1,20 @@
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/klauspost/compress v1.15.0 h1:xqfchp4whNFxn5A4XFyyYtitiWI8Hy5EW59jEwcyL6U=
 github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
@@ -44,2 +58,8 @@
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Index: /trunk/version.go
===================================================================
--- /trunk/version.go	(revision 147)
+++ /trunk/version.go	(revision 147)
@@ -0,0 +1,18 @@
+package yukari
+
+import (
+	"fmt"
+)
+
+var (
+	// Version release version
+	Version = "0.0.1"
+
+	// Commit will be overwritten automatically by the build system
+	Commit = "HEAD"
+)
+
+// FullVersion display the full version and build
+func FullVersion() string {
+	return fmt.Sprintf("%s@%s", Version, Commit)
+}
Index: /trunk/yukari.1
===================================================================
--- /trunk/yukari.1	(revision 146)
+++ /trunk/yukari.1	(revision 147)
@@ -1,59 +1,64 @@
-.TH MORTY "1" "2018" "yukari" "User Commands"
-.SH NAME
-yukari \- Privacy aware web content sanitizer proxy as a service
-.SH SYNOPSIS
-.B yukari
-.RI [ OPTIONS ]
-.br
-.SH DESCRIPTION
-Yukari rewrites web pages to exclude malicious HTML tags and attributes. It
-also replaces external resource references to prevent third party
+.Dd $Mdocdate$
+.Dt YUKARI 1
+.Os
+.Sh NAME
+.Nm yukari
+.Nd Privacy-aware Web Content Sanitizer Proxy As A Service (WCSPAAS)
+.Sh SYNOPSIS
+.Nm
+.Op Fl f Ar string
+.Op Fl proxy Ar string
+.Op Fl proxyenv Ar bool
+.Op Fl socks5 Ar string
+.Op Fl version
+.Sh DESCRIPTION
+Yukari's Gap rewrites web pages to exclude malicious HTML tags and attributes.
+It also replaces external resource references in order to prevent third-party
 information leaks.
-.sp
-The main goal of yukari is to provide a result proxy for searx, but it can be
-used as a standalone sanitizer service too.
-.SH OPTIONS
-.HP
-\fB\-ipv6\fR
-.IP
-Allow IPv6 HTTP requests
-.HP
-\fB\-key\fR string
-.IP
-HMAC url validation key (hexadecimal encoded) \- leave blank to disable
-.HP
-\fB\-listen\fR string
-.IP
-Listen address (default "127.0.0.1:3000")
-.HP
-\fB\-timeout\fR uint
-.IP
-Request timeout (default 2)
-.HP
-\fB\-version\fR
-.IP
-Show version
-.SH BUGS
-Bugs or suggestions? Visit the issue tracker at
-https://git.chaotic.ninja/yakumo.izuru/yukari/issues.
-.SH SEE ALSO
-.BR searx (1)
-.SH LICENSE
-Copyright 2023-present Izuru Yakumo <yakumo.izuru@chaotic.ninja>
-.br
-Copyright 2016-2018 Adam Tauber <asciimoo@gmail.com>
-.br
-Copyright 2016 Alexandre Flament <alex@al-f.net>
-.sp
-This program is free software: you can redistribute it and/or modify it under
-the terms of the GNU Affero General Public License as published by the Free
-Software Foundation, either version 3 of the License, or (at your option) any
-later version.
-.sp
+.Pp
+The main goal of Yukari's Gap is to provide a result proxy for SearX, but it
+can be used as a standalone sanitizer service, too.
+.Sh OPTIONS
+.Bl -tag -width Ds
+.It Fl f Ar path
+Load configuration file from path
+.It Fl proxy Ar string
+Use the specified HTTP proxy (ie: [user:pass@]hostname:port),
+this overrides the
+.Fl socks5
+option and the IPv6 setting
+.It Fl proxyenv Ar bool
+Use a HTTP proxy as set in the environment (such as
+.Ev HTTP_PROXY ,
+.Ev HTTPS_PROXY ,
+.Ev NO_PROXY
+).
+Overrides the
+.Fl proxy ,
+.Fl socks5 ,
+flags and the IPv6 setting
+.It Fl socks5 Ar string
+Use a SOCKS5 proxy (ie: hostname:port), this
+overrides the IPv6 setting
+.El
+.Sh SEE ALSO
+.Xr SearX 1
+.Sh AUTHORS
+.An Adam Tauber Aq Mt asciimoo@gmail.com
+.An Alexandre Flament Aq Mt alex@al-f.net
+.Sh MAINTAINERS
+.An Izuru Yakumo Aq Mt yakumo.izuru@chaotic.ninja
+.Sh BUGS
+Bugs or suggestions?
+Send an email to
+.Aq Mt yukari-dev@chaotic.ninja
+.Sh LICENSE
+This program is free software: you can redistribute it and/or modify it
+under the terms of the GNU Affero General Public License as published
+by the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+.Pp
 This program is distributed in the hope that it will be useful, but WITHOUT ANY
 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
-PARTICULAR PURPOSE.  See the GNU Affero General Public License for more
-details.
-.sp
-You should have received a copy of the GNU Affero General Public License along
-with this program. If not, see <http://www.gnu.org/licenses/>.
+PARTICULAR PURPOSE.
+See the GNU Affero General Public License for more details.
Index: unk/yukari.go
===================================================================
--- /trunk/yukari.go	(revision 146)
+++ 	(revision )
@@ -1,1142 +1,0 @@
-package main
-
-import (
-	"bytes"
-	"crypto/hmac"
-	"crypto/sha256"
-	"encoding/base64"
-	"encoding/hex"
-	"errors"
-	"flag"
-	"fmt"
-	"html/template"
-	"io"
-	"log"
-	"mime"
-	"net/url"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-	"unicode/utf8"
-
-	"github.com/valyala/fasthttp"
-	"github.com/valyala/fasthttp/fasthttpproxy"
-	"golang.org/x/net/html"
-	"golang.org/x/net/html/charset"
-	"golang.org/x/text/encoding"
-
-	"marisa.chaotic.ninja/yukari/config"
-	"marisa.chaotic.ninja/yukari/contenttype"
-)
-
-const (
-	STATE_DEFAULT     int = 0
-	STATE_IN_STYLE    int = 1
-	STATE_IN_NOSCRIPT int = 2
-)
-
-const VERSION = "v0.2.1"
-
-const MAX_REDIRECT_COUNT = 5
-
-var CLIENT *fasthttp.Client = &fasthttp.Client{
-	MaxResponseBodySize: 10 * 1024 * 1024, // 10M
-	ReadBufferSize:      16 * 1024,        // 16K
-}
-
-var cfg *config.Config = config.DefaultConfig
-
-var ALLOWED_CONTENTTYPE_FILTER contenttype.Filter = contenttype.NewFilterOr([]contenttype.Filter{
-	// html
-	contenttype.NewFilterEquals("text", "html", ""),
-	contenttype.NewFilterEquals("application", "xhtml", "xml"),
-	// css
-	contenttype.NewFilterEquals("text", "css", ""),
-	// images
-	contenttype.NewFilterEquals("image", "gif", ""),
-	contenttype.NewFilterEquals("image", "png", ""),
-	contenttype.NewFilterEquals("image", "jpeg", ""),
-	contenttype.NewFilterEquals("image", "pjpeg", ""),
-	contenttype.NewFilterEquals("image", "webp", ""),
-	contenttype.NewFilterEquals("image", "tiff", ""),
-	contenttype.NewFilterEquals("image", "vnd.microsoft.icon", ""),
-	contenttype.NewFilterEquals("image", "bmp", ""),
-	contenttype.NewFilterEquals("image", "x-ms-bmp", ""),
-	contenttype.NewFilterEquals("image", "x-icon", ""),
-	contenttype.NewFilterEquals("image", "svg", "xml"),
-	// fonts
-	contenttype.NewFilterEquals("application", "font-otf", ""),
-	contenttype.NewFilterEquals("application", "font-ttf", ""),
-	contenttype.NewFilterEquals("application", "font-woff", ""),
-	contenttype.NewFilterEquals("application", "vnd.ms-fontobject", ""),
-})
-
-var ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER contenttype.Filter = contenttype.NewFilterOr([]contenttype.Filter{
-	// texts
-	contenttype.NewFilterEquals("text", "csv", ""),
-	contenttype.NewFilterEquals("text", "tab-separated-values", ""),
-	contenttype.NewFilterEquals("text", "plain", ""),
-	// API
-	contenttype.NewFilterEquals("application", "json", ""),
-	// Documents
-	contenttype.NewFilterEquals("application", "x-latex", ""),
-	contenttype.NewFilterEquals("application", "pdf", ""),
-	contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.text", ""),
-	contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.spreadsheet", ""),
-	contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.presentation", ""),
-	contenttype.NewFilterEquals("application", "vnd.oasis.opendocument.graphics", ""),
-	// Compressed archives
-	contenttype.NewFilterEquals("application", "zip", ""),
-	contenttype.NewFilterEquals("application", "gzip", ""),
-	contenttype.NewFilterEquals("application", "x-compressed", ""),
-	contenttype.NewFilterEquals("application", "x-gtar", ""),
-	contenttype.NewFilterEquals("application", "x-compress", ""),
-	// Generic binary
-	contenttype.NewFilterEquals("application", "octet-stream", ""),
-})
-
-var ALLOWED_CONTENTTYPE_PARAMETERS map[string]bool = map[string]bool{
-	"charset": true,
-}
-
-var UNSAFE_ELEMENTS [][]byte = [][]byte{
-	[]byte("applet"),
-	[]byte("canvas"),
-	[]byte("embed"),
-	[]byte("math"),
-	[]byte("script"),
-	[]byte("svg"),
-}
-
-var SAFE_ATTRIBUTES [][]byte = [][]byte{
-	[]byte("abbr"),
-	[]byte("accesskey"),
-	[]byte("align"),
-	[]byte("alt"),
-	[]byte("as"),
-	[]byte("autocomplete"),
-	[]byte("charset"),
-	[]byte("checked"),
-	[]byte("class"),
-	[]byte("content"),
-	[]byte("contenteditable"),
-	[]byte("contextmenu"),
-	[]byte("dir"),
-	[]byte("for"),
-	[]byte("height"),
-	[]byte("hidden"),
-	[]byte("hreflang"),
-	[]byte("id"),
-	[]byte("lang"),
-	[]byte("media"),
-	[]byte("method"),
-	[]byte("name"),
-	[]byte("nowrap"),
-	[]byte("placeholder"),
-	[]byte("property"),
-	[]byte("rel"),
-	[]byte("spellcheck"),
-	[]byte("tabindex"),
-	[]byte("target"),
-	[]byte("title"),
-	[]byte("translate"),
-	[]byte("type"),
-	[]byte("value"),
-	[]byte("width"),
-}
-
-var LINK_REL_SAFE_VALUES [][]byte = [][]byte{
-	[]byte("alternate"),
-	[]byte("archives"),
-	[]byte("author"),
-	[]byte("copyright"),
-	[]byte("first"),
-	[]byte("help"),
-	[]byte("icon"),
-	[]byte("index"),
-	[]byte("last"),
-	[]byte("license"),
-	[]byte("manifest"),
-	[]byte("next"),
-	[]byte("pingback"),
-	[]byte("prev"),
-	[]byte("publisher"),
-	[]byte("search"),
-	[]byte("shortcut icon"),
-	[]byte("stylesheet"),
-	[]byte("up"),
-}
-
-var LINK_HTTP_EQUIV_SAFE_VALUES [][]byte = [][]byte{
-	// X-UA-Compatible will be added automaticaly, so it can be skipped
-	[]byte("date"),
-	[]byte("last-modified"),
-	[]byte("refresh"), // URL rewrite
-	[]byte("content-language"),
-}
-
-var CSS_URL_REGEXP *regexp.Regexp = regexp.MustCompile("url\\((['\"]?)[ \\t\\f]*([\u0009\u0021\u0023-\u0026\u0028\u002a-\u007E]+)(['\"]?)\\)?")
-
-type Proxy struct {
-	Key            []byte
-	RequestTimeout time.Duration
-	FollowRedirect bool
-}
-
-type RequestConfig struct {
-	Key          []byte
-	BaseURL      *url.URL
-	BodyInjected bool
-}
-
-type HTMLBodyExtParam struct {
-	BaseURL     string
-	HasYukariKey bool
-	URLParamName string
-}
-
-type HTMLFormExtParam struct {
-	BaseURL   string
-	YukariHash string
-	URLParamName string
-	HashParamName string
-}
-type HTMLMainPageFormParam struct {
-	URLParamName string
-}
-
-var HTML_FORM_EXTENSION *template.Template
-var HTML_BODY_EXTENSION *template.Template
-var HTML_MAIN_PAGE_FORM *template.Template
-var HTML_HEAD_CONTENT_TYPE string = `<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
-<meta http-equiv="X-UA-Compatible" content="IE=edge">
-<meta name="referrer" content="no-referrer">
-`
-
-var YUKARI_HTML_PAGE_START string = `<!doctype html>
-<html>
-  <head>
-    <title>Yukari's Gap</title>
-    <meta name="viewport" content="width=device-width, initial-scale=1 , maximum-scale=1.0, user-scalable=1" />
-    <style>
-      html { height: 100%; }
-      body { min-height : 100%; display: flex; flex-direction:column; font-family: sans-serif; text-align: center; color: #BC4BFC; background: #240039; margin: 0;
-      padding: 0; font-size: 1.1em; }
-      input { border: 1px solid #888; padding: 0.3em; color: #BC4BFC; background: #202020; font-size: 1.1em; }
-      input[placeholder] { width:80%; }
-      a { text-decoration: none; #9529B9; }
-      h1, h2 { font-weight: 200; margin-bottom: 2rem; }
-      h1 { font-size: 3em; }
-      .container { flex:1; min-height: 100%; margin-bottom: 1em; }
-      .footer { margin: 1em; }
-      .footer p { font-size: 0.8em; }
-    </style>
-  </head>
-  <body>
-    <div class="container">
-      <h1>Yukari's Gap</h1>
-`
-
-var YUKARI_HTML_PAGE_END string = `
-    </div>
-    <div class="footer">
-      <p>Yukari 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 />
-	<a href="https://git.chaotic.ninja/usr/yakumo_izuru/yukari">view on 「混沌とした 忍者」Git</a>
-		</p>
-	</div>
-</body>
-</html>`
-
-var FAVICON_BYTES []byte
-
-func init() {
-	FaviconBase64 := "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII"
-
-	FAVICON_BYTES, _ = base64.StdEncoding.DecodeString(FaviconBase64)
-	var err error
-	HTML_FORM_EXTENSION, err = template.New("html_form_extension").Parse(
-		`<input type="hidden" name="yukariurl" value="{{.BaseURL}}" />{{if .YukariHash}}<input type="hidden" name="yukarihash" value="{{.YukariHash}}" />{{end}}`)
-	if err != nil {
-		panic(err)
-	}
-	HTML_BODY_EXTENSION, err = template.New("html_body_extension").Parse(`
-<input type="checkbox" id="yukaritoggle" autocomplete="off" />
-<div id="yukariheader">
-  <form method="get">
-    <label for="yukaritoggle">hide</label>
-    <span><a href="/">Yukari's Gap</a></span>
-    <input type="url" value="{{.BaseURL}}" name="{{.URLParamName}}" {{if .HasYukariKey }}readonly="true"{{end}} />
-    This is a <a href="https://git.chaotic.ninja/usr/yakumo_izuru/yukari">proxified and sanitized</a> view of the page, visit <a href="{{.BaseURL}}" rel="noreferrer">original site</a>.
-  </form>
-</div>
-<style>
-body{ position: absolute !important; top: 42px !important; left: 0 !important; right: 0 !important; bottom: 0 !important; }
-#yukariheader { position: fixed; margin: 0; box-sizing: border-box; -webkit-box-sizing: border-box; top: 0; left: 0; right: 0; z-index: 2147483647 !important; font-size: 12px; line-height: normal; border-width: 0px 0px 2px 0; border-style: solid; border-color: #9826FF; background: #33004A; padding: 4px; color: #D881FF; height: 42px; }
-#yukariheader * { padding: 0; margin: 0; }
-#yukariheader p { padding: 0 0 0.7em 0; display: block; }
-#yukariheader a { color: #8934DB; font-weight: bold; display: inline; }
-#yukariheader label { text-align: right; cursor: pointer; position: fixed; right: 4px; top: 4px; display: block; color: #444; }
-#yukariheader > form > span { font-size: 24px; font-weight: bold; margin-right: 20px; margin-left: 20px; }
-input[type=checkbox]#yukaritoggle { display: none; }
-input[type=checkbox]#yukaritoggle:checked ~ div { display: none; visibility: hidden; }
-#yukariheader input[type=url] { width: 50%; padding: 4px; font-size: 16px; }
-</style>
-`)
-	if err != nil {
-		panic(err)
-	}
-	HTML_MAIN_PAGE_FORM, err = template.New("html_main_page_form").Parse(`
-	<form action="post">
-	Visit url: <input placeholder="https://url.." name="{{.URLParamName}}" autofocus />
-	<input type="submit" value="go" />
-	</form>`)
-	if err != nil {
-		panic(err)
-	}
-}
-
-func (p *Proxy) RequestHandler(ctx *fasthttp.RequestCtx) {
-
-	if appRequestHandler(ctx) {
-		return
-	}
-
-	requestHash := popRequestParam(ctx, []byte(cfg.HashParameter))
-
-	requestURI := popRequestParam(ctx, []byte(cfg.UrlParameter))
-
-	if requestURI == nil {
-		p.serveMainPage(ctx, 200, nil)
-		return
-	}
-
-	if p.Key != nil {
-		if !verifyRequestURI(requestURI, requestHash, p.Key) {
-			// HTTP status code 403 : Forbidden
-			error_message := fmt.Sprintf(`invalid "%s" parameter. hint: Hash URL Parameter`, cfg.HashParameter)
-			p.serveMainPage(ctx, 403, errors.New(error_message))
-			return
-		}
-	}
-
-	requestURIQuery := ctx.QueryArgs().QueryString()
-	if len(requestURIQuery) > 0 {
-		if bytes.ContainsRune(requestURI, '?') {
-			requestURI = append(requestURI, '&')
-		} else {
-			requestURI = append(requestURI, '?')
-		}
-		requestURI = append(requestURI, requestURIQuery...)
-	}
-
-	p.ProcessUri(ctx, string(requestURI), 0)
-}
-
-func (p *Proxy) ProcessUri(ctx *fasthttp.RequestCtx, requestURIStr string, redirectCount int) {
-	parsedURI, err := url.Parse(requestURIStr)
-
-	if err != nil {
-		// HTTP status code 500 : Internal Server Error
-		p.serveMainPage(ctx, 500, err)
-		return
-	}
-
-	if parsedURI.Scheme == "" {
-		requestURIStr = "https://" + requestURIStr
-		parsedURI, err = url.Parse(requestURIStr)
-		if err != nil {
-			p.serveMainPage(ctx, 500, err)
-			return
-		}
-	}
-
-	// Serve an intermediate page for protocols other than HTTP(S)
-	if (parsedURI.Scheme != "http" && parsedURI.Scheme != "https") || strings.HasSuffix(parsedURI.Host, ".onion") {
-		p.serveExitYukariPage(ctx, parsedURI)
-		return
-	}
-
-	req := fasthttp.AcquireRequest()
-	defer fasthttp.ReleaseRequest(req)
-	req.SetConnectionClose()
-
-	if cfg.Debug {
-		log.Println(string(ctx.Method()), requestURIStr)
-	}
-
-	req.SetRequestURI(requestURIStr)
-	req.Header.SetUserAgentBytes([]byte("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:112.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36"))
-
-	resp := fasthttp.AcquireResponse()
-	defer fasthttp.ReleaseResponse(resp)
-
-	req.Header.SetMethodBytes(ctx.Method())
-	if ctx.IsPost() || ctx.IsPut() {
-		req.SetBody(ctx.PostBody())
-	}
-
-	err = CLIENT.DoTimeout(req, resp, p.RequestTimeout)
-
-	if err != nil {
-		if err == fasthttp.ErrTimeout {
-			// HTTP status code 504 : Gateway Time-Out
-			p.serveMainPage(ctx, 504, err)
-		} else {
-			// HTTP status code 500 : Internal Server Error
-			p.serveMainPage(ctx, 500, err)
-		}
-		return
-	}
-
-	if resp.StatusCode() != 200 {
-		switch resp.StatusCode() {
-		case 301, 302, 303, 307, 308:
-			loc := resp.Header.Peek("Location")
-			if loc != nil {
-				if p.FollowRedirect && ctx.IsGet() {
-					// GET method: Yukari follows the redirect
-					if redirectCount < MAX_REDIRECT_COUNT {
-						if cfg.Debug {
-							log.Println("follow redirect to", string(loc))
-						}
-						p.ProcessUri(ctx, string(loc), redirectCount+1)
-					} else {
-						p.serveMainPage(ctx, 310, errors.New("Too many redirects"))
-					}
-					return
-				} else {
-					// Other HTTP methods: Yukari does NOT follow the redirect
-					rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI}
-					url, err := rc.ProxifyURI(loc)
-					if err == nil {
-						ctx.SetStatusCode(resp.StatusCode())
-						ctx.Response.Header.Add("Location", url)
-						if cfg.Debug {
-							log.Println("redirect to", string(loc))
-						}
-						return
-					}
-				}
-			}
-		}
-		error_message := fmt.Sprintf("invalid response: %d (%s)", resp.StatusCode(), requestURIStr)
-		p.serveMainPage(ctx, resp.StatusCode(), errors.New(error_message))
-		return
-	}
-
-	contentTypeBytes := resp.Header.Peek("Content-Type")
-
-	if contentTypeBytes == nil {
-		// HTTP status code 503 : Service Unavailable
-		p.serveMainPage(ctx, 503, errors.New("invalid content type"))
-		return
-	}
-
-	contentTypeString := string(contentTypeBytes)
-
-	// decode Content-Type header
-	contentType, error := contenttype.ParseContentType(contentTypeString)
-	if error != nil {
-		// HTTP status code 503 : Service Unavailable
-		p.serveMainPage(ctx, 503, errors.New("invalid content type"))
-		return
-	}
-
-	// content-disposition
-	contentDispositionBytes := ctx.Request.Header.Peek("Content-Disposition")
-
-	// check content type
-	if !ALLOWED_CONTENTTYPE_FILTER(contentType) {
-		// it is not a usual content type
-		if ALLOWED_CONTENTTYPE_ATTACHMENT_FILTER(contentType) {
-			// force attachment for allowed content type
-			contentDispositionBytes = contentDispositionForceAttachment(contentDispositionBytes, parsedURI)
-		} else {
-			// deny access to forbidden content type
-			// HTTP status code 403 : Forbidden
-			p.serveMainPage(ctx, 403, errors.New("forbidden content type "+parsedURI.String()))
-			return
-		}
-	}
-
-	// HACK : replace */xhtml by text/html
-	if contentType.SubType == "xhtml" {
-		contentType.TopLevelType = "text"
-		contentType.SubType = "html"
-		contentType.Suffix = ""
-	}
-
-	// conversion to UTF-8
-	var responseBody []byte
-
-	if contentType.TopLevelType == "text" {
-		e, ename, _ := charset.DetermineEncoding(resp.Body(), contentTypeString)
-		if (e != encoding.Nop) && (!strings.EqualFold("utf-8", ename)) {
-			responseBody, err = e.NewDecoder().Bytes(resp.Body())
-			if err != nil {
-				// HTTP status code 503 : Service Unavailable
-				p.serveMainPage(ctx, 503, err)
-				return
-			}
-		} else {
-			responseBody = resp.Body()
-		}
-		// update the charset or specify it
-		contentType.Parameters["charset"] = "UTF-8"
-	} else {
-		responseBody = resp.Body()
-	}
-
-	//
-	contentType.FilterParameters(ALLOWED_CONTENTTYPE_PARAMETERS)
-
-	// set the content type
-	ctx.SetContentType(contentType.String())
-
-	// output according to MIME type
-	switch {
-	case contentType.SubType == "css" && contentType.Suffix == "":
-		sanitizeCSS(&RequestConfig{Key: p.Key, BaseURL: parsedURI}, ctx, responseBody)
-	case contentType.SubType == "html" && contentType.Suffix == "":
-		rc := &RequestConfig{Key: p.Key, BaseURL: parsedURI}
-		sanitizeHTML(rc, ctx, responseBody)
-		if !rc.BodyInjected {
-			p := HTMLBodyExtParam{rc.BaseURL.String(), false, cfg.UrlParameter}
-			if len(rc.Key) > 0 {
-				p.HasYukariKey = true
-			}
-			err := HTML_BODY_EXTENSION.Execute(ctx, p)
-			if err != nil {
-				if cfg.Debug {
-					fmt.Println("failed to inject body extension", err)
-				}
-			}
-		}
-	default:
-		if contentDispositionBytes != nil {
-			ctx.Response.Header.AddBytesV("Content-Disposition", contentDispositionBytes)
-		}
-		ctx.Write(responseBody)
-	}
-}
-
-// force content-disposition to attachment
-func contentDispositionForceAttachment(contentDispositionBytes []byte, url *url.URL) []byte {
-	var contentDispositionParams map[string]string
-
-	if contentDispositionBytes != nil {
-		var err error
-		_, contentDispositionParams, err = mime.ParseMediaType(string(contentDispositionBytes))
-		if err != nil {
-			contentDispositionParams = make(map[string]string)
-		}
-	} else {
-		contentDispositionParams = make(map[string]string)
-	}
-
-	_, fileNameDefined := contentDispositionParams["filename"]
-	if !fileNameDefined {
-		// TODO : sanitize filename
-		contentDispositionParams["fileName"] = filepath.Base(url.Path)
-	}
-
-	return []byte(mime.FormatMediaType("attachment", contentDispositionParams))
-}
-
-func appRequestHandler(ctx *fasthttp.RequestCtx) bool {
-	// serve robots.txt
-	if bytes.Equal(ctx.Path(), []byte("/robots.txt")) {
-		ctx.SetContentType("text/plain")
-		ctx.Write([]byte("User-Agent: *\nDisallow: /\n"))
-		return true
-	}
-
-	// server favicon.ico
-	if bytes.Equal(ctx.Path(), []byte("/favicon.ico")) {
-		ctx.SetContentType("image/png")
-		ctx.Write(FAVICON_BYTES)
-		return true
-	}
-
-	return false
-}
-
-func popRequestParam(ctx *fasthttp.RequestCtx, paramName []byte) []byte {
-	param := ctx.QueryArgs().PeekBytes(paramName)
-
-	if param == nil {
-		param = ctx.PostArgs().PeekBytes(paramName)
-		ctx.PostArgs().DelBytes(paramName)
-	}
-	ctx.QueryArgs().DelBytes(paramName)
-
-	return param
-}
-
-func sanitizeCSS(rc *RequestConfig, out io.Writer, css []byte) {
-	// TODO
-
-	urlSlices := CSS_URL_REGEXP.FindAllSubmatchIndex(css, -1)
-
-	if urlSlices == nil {
-		out.Write(css)
-		return
-	}
-
-	startIndex := 0
-
-	for _, s := range urlSlices {
-		urlStart := s[4]
-		urlEnd := s[5]
-
-		if uri, err := rc.ProxifyURI(css[urlStart:urlEnd]); err == nil {
-			out.Write(css[startIndex:urlStart])
-			out.Write([]byte(uri))
-			startIndex = urlEnd
-		} else if cfg.Debug {
-			log.Println("cannot proxify css uri:", string(css[urlStart:urlEnd]))
-		}
-	}
-	if startIndex < len(css) {
-		out.Write(css[startIndex:len(css)])
-	}
-}
-
-func sanitizeHTML(rc *RequestConfig, out io.Writer, htmlDoc []byte) {
-	r := bytes.NewReader(htmlDoc)
-	decoder := html.NewTokenizer(r)
-	decoder.AllowCDATA(true)
-
-	unsafeElements := make([][]byte, 0, 8)
-	state := STATE_DEFAULT
-	for {
-		token := decoder.Next()
-		if token == html.ErrorToken {
-			err := decoder.Err()
-			if err != io.EOF {
-				log.Println("failed to parse HTML")
-			}
-			break
-		}
-
-		if len(unsafeElements) == 0 {
-
-			switch token {
-			case html.StartTagToken, html.SelfClosingTagToken:
-				tag, hasAttrs := decoder.TagName()
-				safe := !inArray(tag, UNSAFE_ELEMENTS)
-				if !safe {
-					if token != html.SelfClosingTagToken {
-						var unsafeTag []byte = make([]byte, len(tag))
-						copy(unsafeTag, tag)
-						unsafeElements = append(unsafeElements, unsafeTag)
-					}
-					break
-				}
-				if bytes.Equal(tag, []byte("base")) {
-					for {
-						attrName, attrValue, moreAttr := decoder.TagAttr()
-						if bytes.Equal(attrName, []byte("href")) {
-							parsedURI, err := url.Parse(string(attrValue))
-							if err == nil {
-								rc.BaseURL = parsedURI
-							}
-						}
-						if !moreAttr {
-							break
-						}
-					}
-					break
-				}
-				if bytes.Equal(tag, []byte("noscript")) {
-					state = STATE_IN_NOSCRIPT
-					break
-				}
-				var attrs [][][]byte
-				if hasAttrs {
-					for {
-						attrName, attrValue, moreAttr := decoder.TagAttr()
-						attrs = append(attrs, [][]byte{
-							attrName,
-							attrValue,
-							[]byte(html.EscapeString(string(attrValue))),
-						})
-						if !moreAttr {
-							break
-						}
-					}
-				}
-				if bytes.Equal(tag, []byte("link")) {
-					sanitizeLinkTag(rc, out, attrs)
-					break
-				}
-
-				if bytes.Equal(tag, []byte("meta")) {
-					sanitizeMetaTag(rc, out, attrs)
-					break
-				}
-
-				fmt.Fprintf(out, "<%s", tag)
-
-				if hasAttrs {
-					sanitizeAttrs(rc, out, attrs)
-				}
-
-				if token == html.SelfClosingTagToken {
-					fmt.Fprintf(out, " />")
-				} else {
-					fmt.Fprintf(out, ">")
-					if bytes.Equal(tag, []byte("style")) {
-						state = STATE_IN_STYLE
-					}
-				}
-
-				if bytes.Equal(tag, []byte("head")) {
-					fmt.Fprintf(out, HTML_HEAD_CONTENT_TYPE)
-				}
-
-				if bytes.Equal(tag, []byte("form")) {
-					var formURL *url.URL
-					for _, attr := range attrs {
-						if bytes.Equal(attr[0], []byte("action")) {
-							formURL, _ = url.Parse(string(attr[1]))
-							formURL = mergeURIs(rc.BaseURL, formURL)
-							break
-						}
-					}
-					if formURL == nil {
-						formURL = rc.BaseURL
-					}
-					urlStr := formURL.String()
-					var key string
-					if rc.Key != nil {
-						key = hash(urlStr, rc.Key)
-					}
-					err := HTML_FORM_EXTENSION.Execute(out, HTMLFormExtParam{urlStr, key, cfg.UrlParameter, cfg.HashParameter})
-					if err != nil {
-						if cfg.Debug {
-							fmt.Println("failed to inject body extension", err)
-						}
-					}
-				}
-
-			case html.EndTagToken:
-				tag, _ := decoder.TagName()
-				writeEndTag := true
-				switch string(tag) {
-				case "body":
-					p := HTMLBodyExtParam{rc.BaseURL.String(), false, cfg.UrlParameter}
-					if len(rc.Key) > 0 {
-						p.HasYukariKey = true
-					}
-					err := HTML_BODY_EXTENSION.Execute(out, p)
-					if err != nil {
-						if cfg.Debug {
-							fmt.Println("failed to inject body extension", err)
-						}
-					}
-					rc.BodyInjected = true
-				case "style":
-					state = STATE_DEFAULT
-				case "noscript":
-					state = STATE_DEFAULT
-					writeEndTag = false
-				}
-				// skip noscript tags - only the tag, not the content, because javascript is sanitized
-				if writeEndTag {
-					fmt.Fprintf(out, "</%s>", tag)
-				}
-
-			case html.TextToken:
-				switch state {
-				case STATE_DEFAULT:
-					fmt.Fprintf(out, "%s", decoder.Raw())
-				case STATE_IN_STYLE:
-					sanitizeCSS(rc, out, decoder.Raw())
-				case STATE_IN_NOSCRIPT:
-					sanitizeHTML(rc, out, decoder.Raw())
-				}
-
-			case html.CommentToken:
-				// ignore comment. TODO : parse IE conditional comment
-
-			case html.DoctypeToken:
-				out.Write(decoder.Raw())
-			}
-		} else {
-			switch token {
-			case html.StartTagToken, html.SelfClosingTagToken:
-				tag, _ := decoder.TagName()
-				if inArray(tag, UNSAFE_ELEMENTS) {
-					unsafeElements = append(unsafeElements, tag)
-				}
-
-			case html.EndTagToken:
-				tag, _ := decoder.TagName()
-				if bytes.Equal(unsafeElements[len(unsafeElements)-1], tag) {
-					unsafeElements = unsafeElements[:len(unsafeElements)-1]
-				}
-			}
-		}
-	}
-}
-
-func sanitizeLinkTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
-	exclude := false
-	for _, attr := range attrs {
-		attrName := attr[0]
-		attrValue := attr[1]
-		if bytes.Equal(attrName, []byte("rel")) {
-			if !inArray(attrValue, LINK_REL_SAFE_VALUES) {
-				exclude = true
-				break
-			}
-		}
-		if bytes.Equal(attrName, []byte("as")) {
-			if bytes.Equal(attrValue, []byte("script")) {
-				exclude = true
-				break
-			}
-		}
-	}
-
-	if !exclude {
-		out.Write([]byte("<link"))
-		for _, attr := range attrs {
-			sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
-		}
-		out.Write([]byte(">"))
-	}
-}
-
-func sanitizeMetaTag(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
-	var http_equiv []byte
-	var content []byte
-
-	for _, attr := range attrs {
-		attrName := attr[0]
-		attrValue := attr[1]
-		if bytes.Equal(attrName, []byte("http-equiv")) {
-			http_equiv = bytes.ToLower(attrValue)
-			// exclude some <meta http-equiv="..." ..>
-			if !inArray(http_equiv, LINK_HTTP_EQUIV_SAFE_VALUES) {
-				return
-			}
-		}
-		if bytes.Equal(attrName, []byte("content")) {
-			content = attrValue
-		}
-		if bytes.Equal(attrName, []byte("charset")) {
-			// exclude <meta charset="...">
-			return
-		}
-	}
-
-	out.Write([]byte("<meta"))
-	urlIndex := bytes.Index(bytes.ToLower(content), []byte("url="))
-	if bytes.Equal(http_equiv, []byte("refresh")) && urlIndex != -1 {
-		contentUrl := content[urlIndex+4:]
-		// special case of <meta http-equiv="refresh" content="0; url='example.com/url.with.quote.outside'">
-		if len(contentUrl) >= 2 && (contentUrl[0] == byte('\'') || contentUrl[0] == byte('"')) {
-			if contentUrl[0] == contentUrl[len(contentUrl)-1] {
-				contentUrl = contentUrl[1 : len(contentUrl)-1]
-			}
-		}
-		// output proxify result
-		if uri, err := rc.ProxifyURI(contentUrl); err == nil {
-			fmt.Fprintf(out, ` http-equiv="refresh" content="%surl=%s"`, content[:urlIndex], uri)
-		}
-	} else {
-		if len(http_equiv) > 0 {
-			fmt.Fprintf(out, ` http-equiv="%s"`, http_equiv)
-		}
-		sanitizeAttrs(rc, out, attrs)
-	}
-	out.Write([]byte(">"))
-}
-
-func sanitizeAttrs(rc *RequestConfig, out io.Writer, attrs [][][]byte) {
-	for _, attr := range attrs {
-		sanitizeAttr(rc, out, attr[0], attr[1], attr[2])
-	}
-}
-
-func sanitizeAttr(rc *RequestConfig, out io.Writer, attrName, attrValue, escapedAttrValue []byte) {
-	if inArray(attrName, SAFE_ATTRIBUTES) {
-		fmt.Fprintf(out, " %s=\"%s\"", attrName, escapedAttrValue)
-		return
-	}
-	switch string(attrName) {
-	case "src", "href", "action":
-		if uri, err := rc.ProxifyURI(attrValue); err == nil {
-			fmt.Fprintf(out, " %s=\"%s\"", attrName, uri)
-		} else if cfg.Debug {
-			log.Println("cannot proxify uri:", string(attrValue))
-		}
-	case "style":
-		cssAttr := bytes.NewBuffer(nil)
-		sanitizeCSS(rc, cssAttr, attrValue)
-		fmt.Fprintf(out, " %s=\"%s\"", attrName, html.EscapeString(string(cssAttr.Bytes())))
-	}
-}
-
-func mergeURIs(u1, u2 *url.URL) *url.URL {
-	if u2 == nil {
-		return u1
-	}
-	return u1.ResolveReference(u2)
-}
-
-// Sanitized URI : removes all runes bellow 32 (included) as the begining and end of URI, and lower case the scheme.
-// avoid memory allocation (except for the scheme)
-func sanitizeURI(uri []byte) ([]byte, string) {
-	first_rune_index := 0
-	first_rune_seen := false
-	scheme_last_index := -1
-	buffer := bytes.NewBuffer(make([]byte, 0, 10))
-
-	// remove trailing space and special characters
-	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")
-
-	// loop over byte by byte
-	for i, c := range uri {
-		// ignore special characters and space (c <= 32)
-		if c > 32 {
-			// append to the lower case of the rune to buffer
-			if c < utf8.RuneSelf && 'A' <= c && c <= 'Z' {
-				c = c + 'a' - 'A'
-			}
-
-			buffer.WriteByte(c)
-
-			// update the first rune index that is not a special rune
-			if !first_rune_seen {
-				first_rune_index = i
-				first_rune_seen = true
-			}
-
-			if c == ':' {
-				// colon rune found, we have found the scheme
-				scheme_last_index = i
-				break
-			} else if c == '/' || c == '?' || c == '\\' || c == '#' {
-				// special case : most probably a relative URI
-				break
-			}
-		}
-	}
-
-	if scheme_last_index != -1 {
-		// scheme found
-		// copy the "lower case without special runes scheme" before the ":" rune
-		scheme_start_index := scheme_last_index - buffer.Len() + 1
-		copy(uri[scheme_start_index:], buffer.Bytes())
-		// and return the result
-		return uri[scheme_start_index:], buffer.String()
-	} else {
-		// scheme NOT found
-		return uri[first_rune_index:], ""
-	}
-}
-
-func (rc *RequestConfig) ProxifyURI(uri []byte) (string, error) {
-	// sanitize URI
-	uri, scheme := sanitizeURI(uri)
-
-	// remove javascript protocol
-	if scheme == "javascript:" {
-		return "", nil
-	}
-
-	// TODO check malicious data: - e.g. data:script
-	if scheme == "data:" {
-		if bytes.HasPrefix(uri, []byte("data:image/png")) ||
-			bytes.HasPrefix(uri, []byte("data:image/jpeg")) ||
-			bytes.HasPrefix(uri, []byte("data:image/pjpeg")) ||
-			bytes.HasPrefix(uri, []byte("data:image/gif")) ||
-			bytes.HasPrefix(uri, []byte("data:image/webp")) {
-			// should be safe
-			return string(uri), nil
-		} else {
-			// unsafe data
-			return "", nil
-		}
-	}
-
-	// parse the uri
-	u, err := url.Parse(string(uri))
-	if err != nil {
-		return "", err
-	}
-
-	// get the fragment (with the prefix "#")
-	fragment := ""
-	if len(u.Fragment) > 0 {
-		fragment = "#" + u.Fragment
-	}
-
-	// reset the fragment: it is not included in the yukariurl
-	u.Fragment = ""
-
-	// merge the URI with the document URI
-	u = mergeURIs(rc.BaseURL, u)
-
-	// simple internal link ?
-	// some web pages describe the whole link https://same:auth@same.host/same.path?same.query#new.fragment
-	if u.Scheme == rc.BaseURL.Scheme &&
-		(rc.BaseURL.User == nil || (u.User != nil && u.User.String() == rc.BaseURL.User.String())) &&
-		u.Host == rc.BaseURL.Host &&
-		u.Path == rc.BaseURL.Path &&
-		u.RawQuery == rc.BaseURL.RawQuery {
-		// the fragment is the only difference between the document URI and the uri parameter
-		return fragment, nil
-	}
-
-	// return full URI and fragment (if not empty)
-	yukari_uri := u.String()
-
-	if rc.Key == nil {
-		return fmt.Sprintf("./?%s=%s%s", cfg.UrlParameter, url.QueryEscape(yukari_uri), fragment), nil
-	}
-	return fmt.Sprintf("./?%s=%s&%s=%s%s", cfg.HashParameter, hash(yukari_uri, rc.Key), cfg.UrlParameter, url.QueryEscape(yukari_uri), fragment), nil
-}
-
-func inArray(b []byte, a [][]byte) bool {
-	for _, b2 := range a {
-		if bytes.Equal(b, b2) {
-			return true
-		}
-	}
-	return false
-}
-
-func hash(msg string, key []byte) string {
-	mac := hmac.New(sha256.New, key)
-	mac.Write([]byte(msg))
-	return hex.EncodeToString(mac.Sum(nil))
-}
-
-func verifyRequestURI(uri, hashMsg, key []byte) bool {
-	h := make([]byte, hex.DecodedLen(len(hashMsg)))
-	_, err := hex.Decode(h, hashMsg)
-	if err != nil {
-		if cfg.Debug {
-			log.Println("hmac error:", err)
-		}
-		return false
-	}
-	mac := hmac.New(sha256.New, key)
-	mac.Write(uri)
-	return hmac.Equal(h, mac.Sum(nil))
-}
-
-func (p *Proxy) serveExitYukariPage(ctx *fasthttp.RequestCtx, uri *url.URL) {
-	ctx.SetContentType("text/html")
-	ctx.SetStatusCode(403)
-	ctx.Write([]byte(YUKARI_HTML_PAGE_START))
-	ctx.Write([]byte("<h2>You are about to exit YukariSukima</h2>"))
-	ctx.Write([]byte("<p>Following</p><p><a href=\""))
-	ctx.Write([]byte(html.EscapeString(uri.String())))
-	ctx.Write([]byte("\" rel=\"noreferrer\">"))
-	ctx.Write([]byte(html.EscapeString(uri.String())))
-	ctx.Write([]byte("</a></p><p>the content of this URL will be <b>NOT</b> sanitized.</p>"))
-	ctx.Write([]byte(YUKARI_HTML_PAGE_END))
-}
-
-func (p *Proxy) serveMainPage(ctx *fasthttp.RequestCtx, statusCode int, err error) {
-	ctx.SetContentType("text/html; charset=UTF-8")
-	ctx.SetStatusCode(statusCode)
-	ctx.Write([]byte(YUKARI_HTML_PAGE_START))
-	if err != nil {
-		if cfg.Debug {
-			log.Println("error:", err)
-		}
-		ctx.Write([]byte("<h2>Error: "))
-		ctx.Write([]byte(html.EscapeString(err.Error())))
-		ctx.Write([]byte("</h2>"))
-	}
-	if p.Key == nil {
-		p := HTMLMainPageFormParam{cfg.UrlParameter}
-		err := HTML_MAIN_PAGE_FORM.Execute(ctx, p)
-		if err != nil {
-			if cfg.Debug {
-				fmt.Println("failed to inject main page form", err)
-			}
-		}
-	} else {
-		ctx.Write([]byte(`<h3>Warning! This instance does not support direct URL opening.</h3>`))
-	}
-	ctx.Write([]byte(YUKARI_HTML_PAGE_END))
-}
-
-func main() {
-	listenAddress := flag.String("listen", cfg.ListenAddress, "Listen address")
-	key := flag.String("key", cfg.Key, "HMAC url validation key (base64 encoded) - leave blank to disable validation")
-	IPV6 := flag.Bool("ipv6", cfg.IPV6, "Allow IPv6 HTTP requests")
-	debug := flag.Bool("debug", cfg.Debug, "Debug mode")
-	requestTimeout := flag.Uint("timeout", cfg.RequestTimeout, "Request timeout")
-	followRedirect := flag.Bool("followredirect", cfg.FollowRedirect, "Follow HTTP GET redirect")
-	proxyenv := flag.Bool("proxyenv", false, "Use a HTTP proxy as set in the environment (HTTP_PROXY, HTTPS_PROXY and NO_PROXY). Overrides -proxy, -socks5, -ipv6.")
-	proxy := flag.String("proxy", "", "Use the specified HTTP proxy (ie: '[user:pass@]hostname:port'). Overrides -socks5, -ipv6.")
-	socks5 := flag.String("socks5", "", "Use a SOCKS5 proxy (ie: 'hostname:port'). Overrides -ipv6.")
-	urlParameter := flag.String("urlparam", cfg.UrlParameter, "user-defined requesting string URL parameter name (ie: '/?url=...' or '/?u=...')")
-	hashParameter := flag.String("hashparam", cfg.HashParameter, "user-defined requesting string HASH parameter name (ie: '/?hash=...' or '/?h=...')")
-	version := flag.Bool("version", false, "Show version")
-	flag.Parse()
-
-	cfg.ListenAddress = *listenAddress
-	cfg.Key = *key
-	cfg.IPV6 = *IPV6
-	cfg.Debug = *debug
-	cfg.RequestTimeout = *requestTimeout
-	cfg.FollowRedirect = *followRedirect
-	cfg.UrlParameter = *urlParameter
-	cfg.HashParameter = *hashParameter
-
-	if *version {
-		fmt.Println(VERSION)
-		return
-	}
-
-	if *proxyenv && os.Getenv("HTTP_PROXY") == "" && os.Getenv("HTTPS_PROXY") == "" {
-		log.Fatal("Error -proxyenv is used but no environment variables named 'HTTP_PROXY' and/or 'HTTPS_PROXY' could be found.")
-		os.Exit(1)
-	}
-
-	if *proxyenv {
-		CLIENT.Dial = fasthttpproxy.FasthttpProxyHTTPDialer()
-		log.Println("Using environment defined proxy(ies).")
-	} else if *proxy != "" {
-		CLIENT.Dial = fasthttpproxy.FasthttpHTTPDialer(*proxy)
-		log.Println("Using custom HTTP proxy.")
-	} else if *socks5 != "" {
-		CLIENT.Dial = fasthttpproxy.FasthttpSocksDialer(*socks5)
-		log.Println("Using Socks5 proxy.")
-	} else if cfg.IPV6 {
-		CLIENT.Dial = fasthttp.DialDualStack
-		log.Println("Using dual stack (IPv4/IPv6) direct connections.")
-	} else {
-		CLIENT.Dial = fasthttp.Dial
-		log.Println("Using IPv4 only direct connections.")
-	}
-
-	p := &Proxy{RequestTimeout: time.Duration(cfg.RequestTimeout) * time.Second,
-		FollowRedirect: cfg.FollowRedirect}
-
-	if cfg.Key != "" {
-		var err error
-		p.Key, err = base64.StdEncoding.DecodeString(cfg.Key)
-		if err != nil {
-			log.Fatal("Error parsing -key", err.Error())
-			os.Exit(1)
-		}
-	}
-
-	log.Println("listening on", cfg.ListenAddress)
-
-	if err := fasthttp.ListenAndServe(cfg.ListenAddress, p.RequestHandler); err != nil {
-		log.Fatal("Error in ListenAndServe:", err)
-	}
-}
Index: unk/yukari_test.go
===================================================================
--- /trunk/yukari_test.go	(revision 146)
+++ 	(revision )
@@ -1,227 +1,0 @@
-package main
-
-import (
-	"bytes"
-	"net/url"
-	"testing"
-)
-
-type AttrTestCase struct {
-	AttrName       []byte
-	AttrValue      []byte
-	ExpectedOutput []byte
-}
-
-type SanitizeURITestCase struct {
-	Input          []byte
-	ExpectedOutput []byte
-	ExpectedScheme string
-}
-
-type StringTestCase struct {
-	Input          string
-	ExpectedOutput string
-}
-
-var attrTestData []*AttrTestCase = []*AttrTestCase{
-	&AttrTestCase{
-		[]byte("href"),
-		[]byte("./x"),
-		[]byte(` href="./?yukariurl=http%3A%2F%2F127.0.0.1%2Fx"`),
-	},
-	&AttrTestCase{
-		[]byte("src"),
-		[]byte("http://x.com/y"),
-		[]byte(` src="./?yukariurl=http%3A%2F%2Fx.com%2Fy"`),
-	},
-	&AttrTestCase{
-		[]byte("action"),
-		[]byte("/z"),
-		[]byte(` action="./?yukariurl=http%3A%2F%2F127.0.0.1%2Fz"`),
-	},
-	&AttrTestCase{
-		[]byte("onclick"),
-		[]byte("console.log(document.cookies)"),
-		nil,
-	},
-}
-
-var sanitizeUriTestData []*SanitizeURITestCase = []*SanitizeURITestCase{
-	&SanitizeURITestCase{
-		[]byte("http://example.com/"),
-		[]byte("http://example.com/"),
-		"http:",
-	},
-	&SanitizeURITestCase{
-		[]byte("HtTPs://example.com/     \t"),
-		[]byte("https://example.com/"),
-		"https:",
-	},
-	&SanitizeURITestCase{
-		[]byte("      Ht  TPs://example.com/     \t"),
-		[]byte("https://example.com/"),
-		"https:",
-	},
-	&SanitizeURITestCase{
-		[]byte("javascript:void(0)"),
-		[]byte("javascript:void(0)"),
-		"javascript:",
-	},
-	&SanitizeURITestCase{
-		[]byte("      /path/to/a/file/without/protocol     "),
-		[]byte("/path/to/a/file/without/protocol"),
-		"",
-	},
-	&SanitizeURITestCase{
-		[]byte("      #fragment     "),
-		[]byte("#fragment"),
-		"",
-	},
-	&SanitizeURITestCase{
-		[]byte("      qwertyuiop     "),
-		[]byte("qwertyuiop"),
-		"",
-	},
-	&SanitizeURITestCase{
-		[]byte(""),
-		[]byte(""),
-		"",
-	},
-	&SanitizeURITestCase{
-		[]byte(":"),
-		[]byte(":"),
-		":",
-	},
-	&SanitizeURITestCase{
-		[]byte("   :"),
-		[]byte(":"),
-		":",
-	},
-	&SanitizeURITestCase{
-		[]byte("schéma:"),
-		[]byte("schéma:"),
-		"schéma:",
-	},
-}
-
-var urlTestData []*StringTestCase = []*StringTestCase{
-	&StringTestCase{
-		"http://x.com/",
-		"./?yukariurl=http%3A%2F%2Fx.com%2F",
-	},
-	&StringTestCase{
-		"http://a@x.com/",
-		"./?yukariurl=http%3A%2F%2Fa%40x.com%2F",
-	},
-	&StringTestCase{
-		"#a",
-		"#a",
-	},
-}
-
-func TestAttrSanitizer(t *testing.T) {
-	u, _ := url.Parse("http://127.0.0.1/")
-	rc := &RequestConfig{BaseURL: u}
-	for _, testCase := range attrTestData {
-		out := bytes.NewBuffer(nil)
-		sanitizeAttr(rc, out, testCase.AttrName, testCase.AttrValue, testCase.AttrValue)
-		res, _ := out.ReadBytes(byte(0))
-		if !bytes.Equal(res, testCase.ExpectedOutput) {
-			t.Errorf(
-				`Attribute parse error. Name: "%s", Value: "%s", Expected: %s, Got: "%s"`,
-				testCase.AttrName,
-				testCase.AttrValue,
-				testCase.ExpectedOutput,
-				res,
-			)
-		}
-	}
-}
-
-func TestSanitizeURI(t *testing.T) {
-	for _, testCase := range sanitizeUriTestData {
-		newUrl, scheme := sanitizeURI(testCase.Input)
-		if !bytes.Equal(newUrl, testCase.ExpectedOutput) {
-			t.Errorf(
-				`URL proxifier error. Expected: "%s", Got: "%s"`,
-				testCase.ExpectedOutput,
-				newUrl,
-			)
-		}
-		if scheme != testCase.ExpectedScheme {
-			t.Errorf(
-				`URL proxifier error. Expected: "%s", Got: "%s"`,
-				testCase.ExpectedScheme,
-				scheme,
-			)
-		}
-	}
-}
-
-func TestURLProxifier(t *testing.T) {
-	u, _ := url.Parse("http://127.0.0.1/")
-	rc := &RequestConfig{BaseURL: u}
-	for _, testCase := range urlTestData {
-		newUrl, err := rc.ProxifyURI([]byte(testCase.Input))
-		if err != nil {
-			t.Errorf("Failed to parse URL: %s", testCase.Input)
-		}
-		if newUrl != testCase.ExpectedOutput {
-			t.Errorf(
-				`URL proxifier error. Expected: "%s", Got: "%s"`,
-				testCase.ExpectedOutput,
-				newUrl,
-			)
-		}
-	}
-}
-
-var BENCH_SIMPLE_HTML []byte = []byte(`<!doctype html>
-<html>
- <head>
-  <title>test</title>
- </head>
- <body>
-  <h1>Test heading</h1>
- </body>
-</html>`)
-
-func BenchmarkSanitizeSimpleHTML(b *testing.B) {
-	u, _ := url.Parse("http://127.0.0.1/")
-	rc := &RequestConfig{BaseURL: u}
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		out := bytes.NewBuffer(nil)
-		sanitizeHTML(rc, out, BENCH_SIMPLE_HTML)
-	}
-}
-
-var BENCH_COMPLEX_HTML []byte = []byte(`<!doctype html>
-<html>
- <head>
-  <noscript><meta http-equiv="refresh" content="0; URL=./xy"></noscript>
-  <title>test 2</title>
-  <script> alert('xy'); </script>
-  <link rel="stylesheet" href="./core.bundle.css">
-  <style>
-   html { background: url(./a.jpg); }
-  </style
- </head>
- <body>
-  <h1>Test heading</h1>
-  <img src="b.png" alt="imgtitle" />
-  <form action="/z">
-  <input type="submit" style="background: url(http://aa.bb/cc)" >
-  </form>
- </body>
-</html>`)
-
-func BenchmarkSanitizeComplexHTML(b *testing.B) {
-	u, _ := url.Parse("http://127.0.0.1/")
-	rc := &RequestConfig{BaseURL: u}
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		out := bytes.NewBuffer(nil)
-		sanitizeHTML(rc, out, BENCH_COMPLEX_HTML)
-	}
-}
