diff --git a/apps/backend/internal/plugins/webapp/runtime_bootstrap.go b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go index b57bbdaa5b..7a2f3350a1 100644 --- a/apps/backend/internal/plugins/webapp/runtime_bootstrap.go +++ b/apps/backend/internal/plugins/webapp/runtime_bootstrap.go @@ -75,6 +75,33 @@ const hostRuntimeBootstrap = `(() => { const hostRuntimeBootstrapTag = `` +type runtimeBootstrapNamespace uint8 + +const ( + runtimeBootstrapHTMLNamespace runtimeBootstrapNamespace = iota + runtimeBootstrapSVGNamespace + runtimeBootstrapMathMLNamespace + runtimeBootstrapTemplateTag = "template" +) + +var runtimeBootstrapForeignBreakoutTags = map[string]struct{}{ + "b": {}, "big": {}, "blockquote": {}, "body": {}, "br": {}, "center": {}, "code": {}, "dd": {}, "div": {}, "dl": {}, "dt": {}, "em": {}, "embed": {}, + "h1": {}, "h2": {}, "h3": {}, "h4": {}, "h5": {}, "h6": {}, "head": {}, "hr": {}, "i": {}, "img": {}, "li": {}, "listing": {}, "menu": {}, "meta": {}, + "nobr": {}, "ol": {}, "p": {}, "pre": {}, "ruby": {}, "s": {}, "small": {}, "span": {}, "strong": {}, "strike": {}, "sub": {}, "sup": {}, "table": {}, "tt": {}, + "u": {}, "ul": {}, "var": {}, +} + +type runtimeBootstrapOpenElement struct { + name string + namespace runtimeBootstrapNamespace + childNamespace runtimeBootstrapNamespace +} + +type runtimeBootstrapParserState struct { + elements []runtimeBootstrapOpenElement + templateDepth int +} + func injectRuntimeBootstrap(entry []byte) ([]byte, error) { if int64(len(entry)) > MaxFileBytes { return nil, ErrRuntimeBootstrapUnavailable @@ -94,12 +121,12 @@ func injectRuntimeBootstrap(entry []byte) ([]byte, error) { } func runtimeBootstrapInsertion(entry []byte) (int, error) { - tokenizer := html.NewTokenizer(bytes.NewReader(entry)) tokenizer.SetMaxBuf(len(entry) + 1) position := 0 insertion := -1 fallback := -1 + state := runtimeBootstrapParserState{} for { tokenType := tokenizer.Next() raw := tokenizer.Raw() @@ -112,7 +139,7 @@ func runtimeBootstrapInsertion(entry []byte) (int, error) { if len(raw) == 0 { return -1, ErrRuntimeBootstrapUnavailable } - insertion, fallback = updateRuntimeBootstrapPosition(tokenizer, tokenType, raw, position, insertion, fallback) + insertion, fallback = updateRuntimeBootstrapPosition(tokenizer, tokenType, raw, position, insertion, fallback, &state) position += len(raw) } if position != len(entry) { @@ -121,44 +148,245 @@ func runtimeBootstrapInsertion(entry []byte) (int, error) { if insertion < 0 { insertion = fallback } - if insertion < 0 || insertion > len(entry) { - return -1, ErrRuntimeBootstrapUnavailable + if insertion < 0 { + if state.templateDepth > 0 { + return -1, ErrRuntimeBootstrapUnavailable + } + // HTML supplies implied head and body elements when an entry omits the + // corresponding wrapper tags. Appending here keeps the original doctype + // and encoding declarations intact while placing the script in the + // implied body, outside any inert template content. + insertion = len(entry) } return insertion, nil } -func updateRuntimeBootstrapPosition(tokenizer *html.Tokenizer, tokenType html.TokenType, raw []byte, position, insertion, fallback int) (int, int) { - name, hasTagName := runtimeBootstrapTagName(tokenizer, tokenType) - if !hasTagName { +func updateRuntimeBootstrapPosition(tokenizer *html.Tokenizer, tokenType html.TokenType, raw []byte, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) { + token, hasTag := runtimeBootstrapToken(tokenizer, tokenType) + if !hasTag { return insertion, fallback } - if tokenType == html.StartTagToken { - switch strings.ToLower(name) { - case "script": - if insertion < 0 { - insertion = position - } - case "head": - if fallback < 0 { - fallback = position + len(raw) - } - case "body": - if fallback < 0 { - fallback = position - } - } - return insertion, fallback + tagName := strings.ToLower(token.Data) + if tokenType != html.EndTagToken && state.shouldBreakOutOfForeignContent(tagName, token.Attr) { + state.popForeignContent() } - if fallback < 0 && (strings.EqualFold(name, "head") || strings.EqualFold(name, "body") || strings.EqualFold(name, "html")) { + namespace := state.namespaceForTag(tagName) + if tokenType == html.EndTagToken { + return updateRuntimeBootstrapEndTag(tagName, namespace, position, insertion, fallback, state) + } + return updateRuntimeBootstrapStartTagPosition(tokenizer, token, tokenType, raw, tagName, namespace, position, insertion, fallback, state) +} + +func updateRuntimeBootstrapEndTag(tagName string, namespace runtimeBootstrapNamespace, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) { + state.closeElement(tagName, namespace) + if namespace == runtimeBootstrapHTMLNamespace && state.templateDepth == 0 && fallback < 0 && isRuntimeBootstrapFallbackTag(tagName) { fallback = position } return insertion, fallback } -func runtimeBootstrapTagName(tokenizer *html.Tokenizer, tokenType html.TokenType) (string, bool) { - if tokenType != html.StartTagToken && tokenType != html.EndTagToken { - return "", false +func updateRuntimeBootstrapStartTagPosition(tokenizer *html.Tokenizer, token html.Token, tokenType html.TokenType, raw []byte, tagName string, namespace runtimeBootstrapNamespace, position, insertion, fallback int, state *runtimeBootstrapParserState) (int, int) { + elementNamespace, childNamespace := state.elementNamespaces(tagName, token.Attr) + if elementNamespace != runtimeBootstrapHTMLNamespace { + // The tokenizer otherwise treats some foreign elements, such as SVG + // title, as HTML raw-text elements. + tokenizer.NextIsNotRawText() } - name, _ := tokenizer.TagName() - return string(name), true + if tagName == "script" && tokenType == html.StartTagToken && state.templateDepth == 0 && insertion < 0 { + insertion = position + } + if elementNamespace == runtimeBootstrapHTMLNamespace && tagName == runtimeBootstrapTemplateTag { + state.templateDepth++ + } + if tokenType == html.StartTagToken && namespace == runtimeBootstrapHTMLNamespace && state.templateDepth == 0 { + insertion, fallback = updateRuntimeBootstrapStartTag(tagName, raw, position, insertion, fallback) + } + state.openElement(tagName, elementNamespace, childNamespace, tokenType) + return insertion, fallback +} + +func (state *runtimeBootstrapParserState) namespaceForTag(tagName string) runtimeBootstrapNamespace { + if len(state.elements) == 0 { + return runtimeBootstrapHTMLNamespace + } + top := state.elements[len(state.elements)-1] + if top.namespace == runtimeBootstrapMathMLNamespace && isRuntimeBootstrapMathMLTextIntegrationPoint(top.name) && tagName != "mglyph" && tagName != "malignmark" { + return runtimeBootstrapHTMLNamespace + } + return top.childNamespace +} + +func (state *runtimeBootstrapParserState) shouldBreakOutOfForeignContent(tagName string, attrs []html.Attribute) bool { + if _, ok := runtimeBootstrapForeignBreakoutTags[tagName]; ok { + return true + } + if tagName != "font" { + return false + } + for _, attr := range attrs { + switch strings.ToLower(attr.Key) { + case "color", "face", "size": + return true + } + } + return false +} + +func (state *runtimeBootstrapParserState) popForeignContent() { + for len(state.elements) > 0 { + top := state.elements[len(state.elements)-1] + if top.namespace == runtimeBootstrapHTMLNamespace || top.childNamespace == runtimeBootstrapHTMLNamespace { + return + } + state.elements = state.elements[:len(state.elements)-1] + } +} + +func (state *runtimeBootstrapParserState) elementNamespaces(tagName string, attrs []html.Attribute) (runtimeBootstrapNamespace, runtimeBootstrapNamespace) { + parentNamespace := state.namespaceForTag(tagName) + switch parentNamespace { + case runtimeBootstrapHTMLNamespace: + switch tagName { + case "svg": + return runtimeBootstrapSVGNamespace, runtimeBootstrapSVGNamespace + case "math": + return runtimeBootstrapMathMLNamespace, runtimeBootstrapMathMLNamespace + default: + return runtimeBootstrapHTMLNamespace, runtimeBootstrapHTMLNamespace + } + case runtimeBootstrapSVGNamespace: + if isRuntimeBootstrapSVGHTMLIntegrationPoint(tagName) { + return runtimeBootstrapSVGNamespace, runtimeBootstrapHTMLNamespace + } + return runtimeBootstrapSVGNamespace, runtimeBootstrapSVGNamespace + case runtimeBootstrapMathMLNamespace: + if tagName == "annotation-xml" && hasRuntimeBootstrapHTMLAnnotationEncoding(attrs) { + return runtimeBootstrapMathMLNamespace, runtimeBootstrapHTMLNamespace + } + if isRuntimeBootstrapMathMLTextIntegrationPoint(tagName) { + return runtimeBootstrapMathMLNamespace, runtimeBootstrapHTMLNamespace + } + return runtimeBootstrapMathMLNamespace, runtimeBootstrapMathMLNamespace + default: + return runtimeBootstrapHTMLNamespace, runtimeBootstrapHTMLNamespace + } +} + +func (state *runtimeBootstrapParserState) openElement(name string, namespace, childNamespace runtimeBootstrapNamespace, tokenType html.TokenType) { + if namespace == runtimeBootstrapHTMLNamespace && isRuntimeBootstrapHTMLVoidElement(name) { + return + } + if namespace != runtimeBootstrapHTMLNamespace && tokenType == html.SelfClosingTagToken { + return + } + state.elements = append(state.elements, runtimeBootstrapOpenElement{ + name: name, + namespace: namespace, + childNamespace: childNamespace, + }) +} + +func (state *runtimeBootstrapParserState) closeElement(tagName string, namespace runtimeBootstrapNamespace) { + for index := len(state.elements) - 1; index >= 0; index-- { + element := state.elements[index] + if element.name == tagName && runtimeBootstrapEndTagMatches(element, namespace) { + for _, popped := range state.elements[index:] { + if popped.namespace == runtimeBootstrapHTMLNamespace && popped.name == runtimeBootstrapTemplateTag && state.templateDepth > 0 { + state.templateDepth-- + } + } + state.elements = state.elements[:index] + return + } + if runtimeBootstrapEndTagStopsAt(element, tagName, namespace) { + return + } + } +} + +func runtimeBootstrapEndTagStopsAt(element runtimeBootstrapOpenElement, tagName string, namespace runtimeBootstrapNamespace) bool { + if element.namespace == runtimeBootstrapHTMLNamespace && element.name == runtimeBootstrapTemplateTag && tagName != runtimeBootstrapTemplateTag { + return true + } + if namespace == runtimeBootstrapHTMLNamespace && element.namespace != runtimeBootstrapHTMLNamespace && element.childNamespace == runtimeBootstrapHTMLNamespace && element.name != tagName { + return true + } + return namespace != runtimeBootstrapHTMLNamespace && element.namespace == runtimeBootstrapHTMLNamespace +} + +func runtimeBootstrapEndTagMatches(element runtimeBootstrapOpenElement, namespace runtimeBootstrapNamespace) bool { + if element.namespace == namespace { + return true + } + return namespace == runtimeBootstrapHTMLNamespace && element.namespace != runtimeBootstrapHTMLNamespace && element.childNamespace == runtimeBootstrapHTMLNamespace +} + +func isRuntimeBootstrapSVGHTMLIntegrationPoint(tagName string) bool { + switch tagName { + case "desc", "foreignobject", "title": + return true + default: + return false + } +} + +func isRuntimeBootstrapMathMLTextIntegrationPoint(tagName string) bool { + switch tagName { + case "mi", "mo", "mn", "ms", "mtext": + return true + default: + return false + } +} + +func hasRuntimeBootstrapHTMLAnnotationEncoding(attrs []html.Attribute) bool { + for _, attr := range attrs { + if strings.EqualFold(attr.Key, "encoding") && (strings.EqualFold(attr.Val, "text/html") || strings.EqualFold(attr.Val, "application/xhtml+xml")) { + return true + } + } + return false +} + +func isRuntimeBootstrapHTMLVoidElement(tagName string) bool { + switch tagName { + case "area", "base", "basefont", "bgsound", "br", "col", "embed", "frame", "hr", "img", "input", "keygen", "link", "meta", "param", "source", "track", "wbr": + return true + default: + return false + } +} + +func updateRuntimeBootstrapStartTag(tagName string, raw []byte, position, insertion, fallback int) (int, int) { + switch tagName { + case "script": + if insertion < 0 { + insertion = position + } + case "head": + if fallback < 0 { + fallback = position + len(raw) + } + case "body": + if fallback < 0 { + fallback = position + } + } + return insertion, fallback +} + +func isRuntimeBootstrapFallbackTag(tagName string) bool { + switch tagName { + case "head", "body", "html": + return true + default: + return false + } +} + +func runtimeBootstrapToken(tokenizer *html.Tokenizer, tokenType html.TokenType) (html.Token, bool) { + if tokenType != html.StartTagToken && tokenType != html.SelfClosingTagToken && tokenType != html.EndTagToken { + return html.Token{}, false + } + return tokenizer.Token(), true } diff --git a/apps/backend/internal/plugins/webapp/runtime_test.go b/apps/backend/internal/plugins/webapp/runtime_test.go index 714c89165a..a90d20f6ac 100644 --- a/apps/backend/internal/plugins/webapp/runtime_test.go +++ b/apps/backend/internal/plugins/webapp/runtime_test.go @@ -201,6 +201,117 @@ func TestRuntimeStartupBootstrapPreservesArtifact(t *testing.T) { } } +func TestRuntimeStartupBootstrapSupportsOmittedHTMLWrappers(t *testing.T) { + entry := `
Hello
` + archive := canvasArchive(t, map[string]string{ + "manifest.yaml": staticManifestYAML, + "ui/index.html": entry, + }) + pkg, err := ValidatePackage(bytes.NewReader(archive)) + if err != nil { + t.Fatalf("ValidatePackage: %v", err) + } + artifacts, err := NewArtifactStore(filepath.Join(t.TempDir(), "artifacts")) + if err != nil { + t.Fatalf("NewArtifactStore: %v", err) + } + artifact, err := artifacts.Put(pkg) + if err != nil { + t.Fatalf("Put: %v", err) + } + manager := NewTokenManager(nil) + token, err := manager.Issue(CapabilityBinding{ + UserID: "user-1", InstanceID: "instance-1", ReleaseID: "release-1", WebAppKey: "main", + Placement: "task-canvas", Artifact: artifact, Entry: "ui/index.html", + }, 0) + if err != nil { + t.Fatalf("Issue: %v", err) + } + runtime := NewRuntime(manager, artifacts, nil, nil) + + response := httptest.NewRecorder() + runtime.Serve(response, httptest.NewRequest(http.MethodGet, "/", nil), token, "") + if response.Code != http.StatusOK { + t.Fatalf("entry status = %d, body = %s", response.Code, response.Body.String()) + } + body := response.Body.String() + bootstrap := `` + if !strings.HasPrefix(body, ``) { + t.Fatalf("doctype or encoding prefix changed: %q", body) + } + if strings.Index(body, bootstrap) <= strings.Index(body, "Hello
") { + t.Fatalf("host bootstrap was not appended to the implied body: %q", body) + } + if got := response.Header().Get("Content-Length"); got != fmt.Sprintf("%d", len(body)) { + t.Fatalf("Content-Length = %q, want %d", got, len(body)) + } + stored, err := os.ReadFile(filepath.Join(artifacts.Path(artifact), "ui", "index.html")) + if err != nil { + t.Fatalf("ReadFile stored entry: %v", err) + } + if string(stored) != entry { + t.Fatalf("stored entry changed: %q", stored) + } +} + +func TestInjectRuntimeBootstrapSkipsTemplateContent(t *testing.T) { + entry := `` + result, err := injectRuntimeBootstrap([]byte(entry)) + if err != nil { + t.Fatalf("injectRuntimeBootstrap: %v", err) + } + body := string(result) + bootstrap := `` + bootstrapIndex := strings.Index(body, bootstrap) + templateEnd := strings.Index(body, "") + authoredScript := strings.Index(body, ``, + } { + if _, err := injectRuntimeBootstrap([]byte(entry)); !errors.Is(err, ErrRuntimeBootstrapUnavailable) { + t.Fatalf("injectRuntimeBootstrap(%q) error = %v, want %v", entry, err, ErrRuntimeBootstrapUnavailable) + } + } +} + +func TestInjectRuntimeBootstrapDoesNotTreatForeignTemplateAsInert(t *testing.T) { + entry := `` + result, err := injectRuntimeBootstrap([]byte(entry)) + if err != nil { + t.Fatalf("injectRuntimeBootstrap: %v", err) + } + body := string(result) + bootstrap := `` + bootstrapIndex := strings.Index(body, bootstrap) + foreignScript := strings.Index(body, ``) + if bootstrapIndex < 0 || foreignScript < 0 || bootstrapIndex >= foreignScript { + t.Fatalf("host bootstrap was inserted after foreign executable script: %q", body) + } +} + +func TestInjectRuntimeBootstrapTracksHTMLTemplateAfterForeignBreakout(t *testing.T) { + entry := `` + result, err := injectRuntimeBootstrap([]byte(entry)) + if err != nil { + t.Fatalf("injectRuntimeBootstrap: %v", err) + } + body := string(result) + bootstrap := `` + bootstrapIndex := strings.Index(body, bootstrap) + templateScript := strings.Index(body, ``) + authoredScript := strings.Index(body, `