Compare commits

...

5 Commits
v0.3.4 ... main

Author SHA1 Message Date
Alex Dunmow
5f0cc20bbf chore: checkpoint all working changes 2026-09-05 21:14:15 +08:00
Alex Dunmow
f14846d758 fix(wasmguest): expose host services during register 2026-08-25 12:42:42 +08:00
Alex Dunmow
e2dcbc1fc3 feat: authenticate bridge callers and declare SDK compatibility 2026-08-20 00:24:49 +08:00
Alex Dunmow
731d6b51d1 feat(plugin): declare admin API compatibility 2026-08-19 23:21:47 +08:00
Alex Dunmow
abef5b70da fix(render): reject active-content link schemes 2026-08-19 22:44:40 +08:00
20 changed files with 429 additions and 34 deletions

View File

@ -246,6 +246,9 @@ message BridgeCallRequest {
string service_name = 1; string service_name = 1;
string method = 2; string method = 2;
bytes payload = 3; bytes payload = 3;
// Host-authenticated identity of the consumer plugin. The host derives this
// from the loaded caller; providers must not trust identity in payload.
string caller_plugin = 4;
} }
message BridgeCallResponse { message BridgeCallResponse {

View File

@ -1346,10 +1346,13 @@ func (x *AiToolCallResponse) GetErrorMessage() string {
// capability; payload encoding is agreed between the two plugins (JSON by // capability; payload encoding is agreed between the two plugins (JSON by
// convention). // convention).
type BridgeCallRequest struct { type BridgeCallRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"` Method string `protobuf:"bytes,2,opt,name=method,proto3" json:"method,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
// Host-authenticated identity of the consumer plugin. The host derives this
// from the loaded caller; providers must not trust identity in payload.
CallerPlugin string `protobuf:"bytes,4,opt,name=caller_plugin,json=callerPlugin,proto3" json:"caller_plugin,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1405,6 +1408,13 @@ func (x *BridgeCallRequest) GetPayload() []byte {
return nil return nil
} }
func (x *BridgeCallRequest) GetCallerPlugin() string {
if x != nil {
return x.CallerPlugin
}
return ""
}
type BridgeCallResponse struct { type BridgeCallResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,1,opt,name=payload,proto3" json:"payload,omitempty"`
@ -1842,11 +1852,12 @@ const file_v1_invoke_proto_rawDesc = "" +
"paramsJson\"S\n" + "paramsJson\"S\n" +
"\x12AiToolCallResponse\x12\x18\n" + "\x12AiToolCallResponse\x12\x18\n" +
"\acontent\x18\x01 \x01(\tR\acontent\x12#\n" + "\acontent\x18\x01 \x01(\tR\acontent\x12#\n" +
"\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"h\n" + "\rerror_message\x18\x02 \x01(\tR\ferrorMessage\"\x8d\x01\n" +
"\x11BridgeCallRequest\x12!\n" + "\x11BridgeCallRequest\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" + "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x16\n" +
"\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" + "\x06method\x18\x02 \x01(\tR\x06method\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\".\n" + "\apayload\x18\x03 \x01(\fR\apayload\x12#\n" +
"\rcaller_plugin\x18\x04 \x01(\tR\fcallerPlugin\".\n" +
"\x12BridgeCallResponse\x12\x18\n" + "\x12BridgeCallResponse\x12\x18\n" +
"\apayload\x18\x01 \x01(\fR\apayload\"Q\n" + "\apayload\x18\x01 \x01(\fR\apayload\"Q\n" +
"\x1cDirectoryPanelSectionRequest\x12\x14\n" + "\x1cDirectoryPanelSectionRequest\x12\x14\n" +

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@ -97,10 +98,8 @@ func TestCaptchaVerifiedReadsTrustedHeader(t *testing.T) {
} }
func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) { func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) {
for _, h := range AllTrustedHeaders() { if slices.Contains(AllTrustedHeaders(), HeaderVerifiedCaptcha) {
if h == HeaderVerifiedCaptcha { return
return
}
} }
t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha) t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha)
} }

View File

@ -0,0 +1,37 @@
# Plugin manifests declare admin API compatibility
Plugin admin bundles consume the versioned `@block-ninja/api` browser package.
The existing `block_core` compatibility constraint describes the host plugin
runtime, but it cannot tell a registry or CMS whether an admin bundle is safe
to load against the host's browser API. Treating those surfaces as one version
would couple independent release cycles and allow an otherwise compatible
plugin to fail only after its admin page loads.
Decision: `[compatibility]` gains an optional `admin_api` string. Its value is
a semantic-version constraint for the host-provided browser admin API, for
example `admin_api = ">=0.1.2"`. The manifest/parser layer preserves the value
verbatim; registry and host resolvers own constraint validation and matching.
An omitted or empty value means the plugin has not declared browser API
compatibility. It is not equivalent to an unconstrained wildcard. Automated
latest-compatible resolution that filters by a host admin API version must
therefore fail closed for an undeclared value. This does not change the
separate `block_core` constraint or imply that every plugin has an admin
bundle.
The field is part of the shared manifest model and the CLI's hand-written
`plugin.mod` serializer so version bumps, tag edits, and initialization cannot
silently discard it.
Consequences:
- Registries and hosts can select plugin releases compatible with both the
runtime ABI and browser admin API.
- Headless plugins can continue to omit `admin_api`.
- Plugin authors express a range rather than pinning a single browser package
version.
- Constraint syntax and matching remain the resolver's responsibility rather
than being duplicated in manifest parsers.
Keywords: plugin.mod, compatibility, admin_api, AdminAPI, @block-ninja/api,
semantic version, browser API, fail closed, plugin resolver

View File

@ -0,0 +1,27 @@
# Plugin manifests declare Plugin SDK compatibility
The historical `block_core` compatibility field predates the standalone
Plugin SDK and is ambiguous: CMS core and the plugin-facing SDK have separate
release cycles. Interpreting the same constraint against both versions can
select an artifact that the host cannot load or reject one that is compatible.
Decision: `[compatibility]` gains an optional `plugin_sdk` string containing a
semantic-version constraint for the plugin-facing SDK/ABI, for example
`plugin_sdk = ">=0.3.7"`. Manifest parsing preserves the value verbatim;
registry and host resolvers own constraint validation and matching.
An omitted or empty value is undeclared compatibility, not a wildcard.
Automated resolution against a host SDK version must fail closed, except for
an explicitly bounded migration policy for releases published before this
field existed. `block_core` remains a separate historical constraint and is
not reinterpreted as the CMS application's own version.
Consequences:
- Plugins can state the actual SDK/ABI range they require.
- CMS core can evolve independently of the public Plugin SDK.
- New releases must declare `plugin_sdk` to participate in automatic
SDK-compatible selection.
Keywords: plugin.mod, compatibility, plugin_sdk, PluginSDK, ABI, semantic
version, plugin resolver, fail closed

View File

@ -0,0 +1,27 @@
# Bridge providers receive authenticated caller identity
Bridge payloads are guest-controlled. A provider that uses a payload field as
an ownership namespace lets one plugin impersonate another plugin and mutate
or claim its managed records. The consumer-to-host `BridgeInvokeRequest`
cannot safely carry identity because the consumer constructs that message.
Decision: the host derives the caller plugin from the loaded module or native
plugin registration. It passes that identity to the provider in
`BridgeCallRequest.caller_plugin` and, for Go providers, through
`plugin.WithBridgeCallerPlugin`. Providers read it with
`plugin.BridgeCallerPlugin` and fail closed when an ownership-sensitive call
has no authenticated caller.
The new host-to-provider protobuf field is additive. Older providers ignore
it. New ownership-sensitive providers intentionally reject calls from older
hosts that cannot authenticate a caller. Hosts must never copy a caller name
from opaque bridge payloads or a consumer-authored capability field.
Consequences:
- Providers can derive durable ownership from an authenticated principal.
- Bridge payload schemas do not need security-sensitive source fields.
- Native and Wasm providers observe the same caller context contract.
Keywords: plugin bridge, caller identity, authentication, ownership,
BridgeCallRequest, WithBridgeCallerPlugin, Wasm, confused deputy

View File

@ -0,0 +1,36 @@
# Register sees the current guest host services
Go guest plugins can declare capability-backed runtime surfaces from their
`Register` callback. Bridge providers are the first such surface: the provider
stores its invokable value in the guest-local bridge stub while the stub tells
the CMS host that the named service exists.
`Serve` previously assigned the package-level guest runtime only after
`newGuest` returned. Because `newGuest` runs `Register` synchronously,
`HostServices()` returned an empty `CoreServices` value during registration.
Plugins that correctly nil-checked the bridge silently skipped registration.
The CMS could therefore mark a Wasm plugin loaded while consumers failed with
`bridge: no service registered` during a later load hook.
`newGuest` now makes the guest under construction current for the synchronous
registration pass and restores the previous runtime before returning. `Serve`
then installs the completed guest as before. This keeps construction isolated
for native tests while making the documented `HostServices()` escape hatch
truthful during `Register` on every pooled Wasm instance.
Alternatives rejected were moving bridge registration into `Load`, which runs
on only one pooled instance, and adding bridge names only to the manifest,
which would advertise host availability without installing the guest-local
`BridgeInvokable` value needed by `HOOK_BRIDGE_CALL`.
Consequences:
- Bridge services registered from `Register` exist on every pooled guest
instance and remain callable after CMS startup and hot swap.
- A regression test exercises the observable `Serve` plus
`HOOK_BRIDGE_CALL` contract instead of relying on initialization internals.
- No ABI or protobuf change is required; consumers need a plugin SDK release
containing the corrected Go guest runtime.
Keywords: wasmguest.Serve, newGuest, HostServices, PluginRegistration.Register,
plugin bridge, RegisterService, BridgeInvokable, HOOK_BRIDGE_CALL, wiki content

View File

@ -166,12 +166,12 @@ func validHostname(h string) bool {
if len(h) > 253 { if len(h) > 253 {
return false return false
} }
for _, label := range strings.Split(h, ".") { for label := range strings.SplitSeq(h, ".") {
if label == "" || len(label) > 63 { if label == "" || len(label) > 63 {
return false return false
} }
for _, r := range label { for _, r := range label {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' { if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' {
return false return false
} }
} }

View File

@ -2,6 +2,26 @@ package plugin
import "context" import "context"
type bridgeCallerPluginContextKey struct{}
// WithBridgeCallerPlugin records the host-authenticated plugin name of a
// bridge caller. Hosts and ABI adapters must derive pluginName from the loaded
// caller, never from guest-controlled request data.
func WithBridgeCallerPlugin(ctx context.Context, pluginName string) context.Context {
return context.WithValue(ctx, bridgeCallerPluginContextKey{}, pluginName)
}
// BridgeCallerPlugin returns the host-authenticated plugin name of the
// current bridge caller. Ownership-sensitive providers should fail closed
// when no caller identity is present.
func BridgeCallerPlugin(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
pluginName, ok := ctx.Value(bridgeCallerPluginContextKey{}).(string)
return pluginName, ok && pluginName != ""
}
// PluginBridge allows plugins to share services with each other. // PluginBridge allows plugins to share services with each other.
// Plugins register named services during startup; other plugins look them up at runtime. // Plugins register named services during startup; other plugins look them up at runtime.
// //

25
plugin/bridge_test.go Normal file
View File

@ -0,0 +1,25 @@
package plugin
import (
"context"
"testing"
)
func TestBridgeCallerPlugin(t *testing.T) {
if got, ok := BridgeCallerPlugin(context.Background()); ok || got != "" {
t.Fatalf("BridgeCallerPlugin(background) = %q, %t, want empty, false", got, ok)
}
ctx := WithBridgeCallerPlugin(context.Background(), "website")
if got, ok := BridgeCallerPlugin(ctx); !ok || got != "website" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", got, ok)
}
}
func TestWithBridgeCallerPluginEmptyDoesNotAuthenticateCaller(t *testing.T) {
ctx := WithBridgeCallerPlugin(context.Background(), "spoofed")
ctx = WithBridgeCallerPlugin(ctx, "")
if got, ok := BridgeCallerPlugin(ctx); ok || got != "" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want empty, false", got, ok)
}
}

View File

@ -91,6 +91,8 @@ type ModPublicRoute struct {
type ModCompat struct { type ModCompat struct {
BlockCore string `toml:"block_core"` BlockCore string `toml:"block_core"`
AdminAPI string `toml:"admin_api"`
PluginSDK string `toml:"plugin_sdk"`
} }
type ModRequirement struct { type ModRequirement struct {

View File

@ -1,9 +1,12 @@
package plugin package plugin
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
tomlpkg "github.com/BurntSushi/toml"
) )
func TestParseModFull_BasicFields(t *testing.T) { func TestParseModFull_BasicFields(t *testing.T) {
@ -278,6 +281,8 @@ version = "0.2.0"
[compatibility] [compatibility]
block_core = ">=1.5 <2.0" block_core = ">=1.5 <2.0"
admin_api = ">=0.1.2 <0.2.0"
plugin_sdk = ">=0.3.7 <0.4.0"
[[requires]] [[requires]]
name = "@blockninja/smartblock" name = "@blockninja/smartblock"
@ -291,8 +296,17 @@ version = ">=1.2"
if err != nil { if err != nil {
t.Fatalf("ParseModFull err: %v", err) t.Fatalf("ParseModFull err: %v", err)
} }
if m.Compatibility == nil || m.Compatibility.BlockCore != ">=1.5 <2.0" { if m.Compatibility == nil {
t.Errorf("Compat = %+v", m.Compatibility) t.Fatal("Compatibility is nil")
}
if m.Compatibility.BlockCore != ">=1.5 <2.0" {
t.Errorf("Compat.BlockCore = %q", m.Compatibility.BlockCore)
}
if m.Compatibility.AdminAPI != ">=0.1.2 <0.2.0" {
t.Errorf("Compat.AdminAPI = %q", m.Compatibility.AdminAPI)
}
if m.Compatibility.PluginSDK != ">=0.3.7 <0.4.0" {
t.Errorf("Compat.PluginSDK = %q", m.Compatibility.PluginSDK)
} }
if len(m.Requires) != 2 { if len(m.Requires) != 2 {
t.Fatalf("Requires len = %d, want 2", len(m.Requires)) t.Fatalf("Requires len = %d, want 2", len(m.Requires))
@ -305,6 +319,33 @@ version = ">=1.2"
} }
} }
func TestModCompatPluginSDKRoundTrip(t *testing.T) {
t.Parallel()
want := &ModFile{
Plugin: ModPlugin{Name: "wiki", Version: "0.2.0"},
Compatibility: &ModCompat{
BlockCore: ">=0.3.4",
AdminAPI: ">=0.1.2",
PluginSDK: ">=0.3.7 <0.4.0",
},
}
var encoded bytes.Buffer
if err := tomlpkg.NewEncoder(&encoded).Encode(want); err != nil {
t.Fatalf("encode plugin.mod: %v", err)
}
if !strings.Contains(encoded.String(), `plugin_sdk = ">=0.3.7 <0.4.0"`) {
t.Fatalf("encoded plugin.mod omitted plugin_sdk:\n%s", encoded.String())
}
got, err := ParseModFull(encoded.Bytes())
if err != nil {
t.Fatalf("ParseModFull(round trip): %v", err)
}
if got.Compatibility == nil || got.Compatibility.PluginSDK != want.Compatibility.PluginSDK {
t.Fatalf("round-trip PluginSDK = %#v, want %q", got.Compatibility, want.Compatibility.PluginSDK)
}
}
func TestNormalizeTags_HappyPath(t *testing.T) { func TestNormalizeTags_HappyPath(t *testing.T) {
got, err := NormalizeTags([]string{"dark", "agency", "serif"}) got, err := NormalizeTags([]string{"dark", "agency", "serif"})
if err != nil { if err != nil {

View File

@ -68,7 +68,7 @@ func checkRoutePath(r ModPublicRoute) string {
if strings.HasSuffix(p, "/") { if strings.HasSuffix(p, "/") {
return "no trailing slash; set prefix = true to claim the subtree" return "no trailing slash; set prefix = true to claim the subtree"
} }
for _, seg := range strings.Split(p[1:], "/") { for seg := range strings.SplitSeq(p[1:], "/") {
switch { switch {
case seg == "": case seg == "":
return "empty path segment" return "empty path segment"

View File

@ -100,7 +100,15 @@ func newGuest(reg plugin.PluginRegistration) *guest {
// Pool whose calls fail cleanly, matching the capability stubs. // Pool whose calls fail cleanly, matching the capability stubs.
g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport)) g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport))
g.rag, _ = g.services.RAGService.(*caps.RAGStub) g.rag, _ = g.services.RAGService.(*caps.RAGStub)
// Register callbacks may use HostServices for capability-backed surfaces
// that are not passed as Register parameters, such as bridge services.
// Publish this partially initialized guest only for the synchronous
// registration pass, then restore the previous runtime. Serve installs g
// permanently after newGuest returns.
previous := current
current = g
g.registerErr = runRegister(reg, g.templates, g.blocks) g.registerErr = runRegister(reg, g.templates, g.blocks)
current = previous
return g return g
} }
@ -713,6 +721,7 @@ func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message,
Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable", Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable",
} }
} }
ctx = plugin.WithBridgeCallerPlugin(ctx, req.GetCallerPlugin())
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload()) out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
if err != nil { if err != nil {
return nil, internalError(err.Error()) return nil, internalError(err.Error())

View File

@ -25,6 +25,12 @@ func (c textComponent) Render(_ context.Context, w io.Writer) error {
return err return err
} }
type bridgeInvokableFunc func(context.Context, string, []byte) ([]byte, error)
func (f bridgeInvokableFunc) InvokeBridge(ctx context.Context, method string, payload []byte) ([]byte, error) {
return f(ctx, method, payload)
}
func fixtureRegistration() plugin.PluginRegistration { func fixtureRegistration() plugin.PluginRegistration {
return plugin.PluginRegistration{ return plugin.PluginRegistration{
Name: "fixture", Name: "fixture",
@ -124,6 +130,76 @@ func TestServeInstallsRuntime(t *testing.T) {
} }
} }
func TestServeExposesHostServicesDuringRegister(t *testing.T) {
previous := current
t.Cleanup(func() { current = previous })
Serve(plugin.PluginRegistration{
Name: "bridge-provider",
Version: "0.1.0",
Register: func(_ templates.TemplateRegistry, _ blocks.BlockRegistry) error {
bridge := HostServices().Bridge
if bridge == nil {
return fmt.Errorf("HostServices bridge is unavailable during Register")
}
bridge.RegisterService("bridge-provider", "content", bridgeInvokableFunc(
func(_ context.Context, _ string, _ []byte) ([]byte, error) {
return []byte("registered"), nil
},
))
return nil
},
})
resp := invokeHook(t, current, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
ServiceName: "content",
Method: "apply",
})
if resp.GetError() != nil {
t.Fatalf("bridge call returned error: %v", resp.GetError())
}
bridgeResp := &abiv1.BridgeCallResponse{}
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
}
if got := string(bridgeResp.GetPayload()); got != "registered" {
t.Fatalf("bridge payload = %q, want registered", got)
}
}
func TestBridgeCallProvidesAuthenticatedCallerContext(t *testing.T) {
g := newGuest(fixtureRegistration())
g.services.Bridge.RegisterService("fixture", "content", bridgeInvokableFunc(
func(ctx context.Context, method string, payload []byte) ([]byte, error) {
caller, ok := plugin.BridgeCallerPlugin(ctx)
if !ok || caller != "website" {
t.Fatalf("BridgeCallerPlugin(ctx) = %q, %t, want website, true", caller, ok)
}
if method != "ApplyBundle" || string(payload) != `{"revision":"1"}` {
t.Fatalf("bridge call = %q %q", method, payload)
}
return []byte(`{"created":1}`), nil
},
))
resp := invokeHook(t, g, abiv1.Hook_HOOK_BRIDGE_CALL, &abiv1.BridgeCallRequest{
ServiceName: "content",
Method: "ApplyBundle",
Payload: []byte(`{"revision":"1"}`),
CallerPlugin: "website",
})
if resp.GetError() != nil {
t.Fatalf("bridge call returned error: %v", resp.GetError())
}
bridgeResp := &abiv1.BridgeCallResponse{}
if err := proto.Unmarshal(resp.GetPayload(), bridgeResp); err != nil {
t.Fatalf("unmarshal BridgeCallResponse: %v", err)
}
if got := string(bridgeResp.GetPayload()); got != `{"created":1}` {
t.Fatalf("bridge payload = %q, want report", got)
}
}
func TestDescribeManifest(t *testing.T) { func TestDescribeManifest(t *testing.T) {
g := newGuest(fixtureRegistration()) g := newGuest(fixtureRegistration())
resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1}) resp := invokeHook(t, g, abiv1.Hook_HOOK_DESCRIBE, &abiv1.DescribeRequest{HostAbiVersion: 1})

View File

@ -74,8 +74,8 @@ func methodDocumentation(method protoreflect.MethodDescriptor) (description, doc
} }
paragraph = strings.TrimSpace(strings.ReplaceAll(paragraph, "\n", " ")) paragraph = strings.TrimSpace(strings.ReplaceAll(paragraph, "\n", " "))
methodName := string(method.Name()) methodName := string(method.Name())
if strings.HasPrefix(paragraph, methodName+" ") { if after, ok := strings.CutPrefix(paragraph, methodName+" "); ok {
paragraph = strings.TrimSpace(strings.TrimPrefix(paragraph, methodName+" ")) paragraph = strings.TrimSpace(after)
if paragraph != "" { if paragraph != "" {
runes := []rune(paragraph) runes := []rune(paragraph)
runes[0] = unicode.ToUpper(runes[0]) runes[0] = unicode.ToUpper(runes[0])

View File

@ -65,30 +65,30 @@ func registerMCPFixture(t *testing.T) {
return return
} }
file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{
Name: proto.String("test/mcpfixture.proto"), Name: new("test/mcpfixture.proto"),
Package: proto.String("mcpfixture.v1"), Package: new("mcpfixture.v1"),
Syntax: proto.String("proto3"), Syntax: new("proto3"),
Dependency: []string{"google/protobuf/timestamp.proto"}, Dependency: []string{"google/protobuf/timestamp.proto"},
MessageType: []*descriptorpb.DescriptorProto{{ MessageType: []*descriptorpb.DescriptorProto{{
Name: proto.String("ListArticlesRequest"), Name: new("ListArticlesRequest"),
Field: []*descriptorpb.FieldDescriptorProto{ Field: []*descriptorpb.FieldDescriptorProto{
{Name: proto.String("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()}, {Name: new("search"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum()},
{Name: proto.String("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()}, {Name: new("page"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum()},
{Name: proto.String("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".google.protobuf.Timestamp")}, {Name: new("published_after"), Number: proto.Int32(3), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: new(".google.protobuf.Timestamp")},
}, },
}, { }, {
Name: proto.String("ListArticlesResponse"), Name: new("ListArticlesResponse"),
}}, }},
Service: []*descriptorpb.ServiceDescriptorProto{{ Service: []*descriptorpb.ServiceDescriptorProto{{
Name: proto.String("WikiService"), Name: new("WikiService"),
Method: []*descriptorpb.MethodDescriptorProto{{ Method: []*descriptorpb.MethodDescriptorProto{{
Name: proto.String("ListArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), Name: new("ListArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"),
}, { }, {
Name: proto.String("StreamArticles"), InputType: proto.String(".mcpfixture.v1.ListArticlesRequest"), OutputType: proto.String(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: proto.Bool(true), Name: new("StreamArticles"), InputType: new(".mcpfixture.v1.ListArticlesRequest"), OutputType: new(".mcpfixture.v1.ListArticlesResponse"), ServerStreaming: new(true),
}}, }},
}}, }},
SourceCodeInfo: &descriptorpb.SourceCodeInfo{Location: []*descriptorpb.SourceCodeInfo_Location{{ SourceCodeInfo: &descriptorpb.SourceCodeInfo{Location: []*descriptorpb.SourceCodeInfo_Location{{
Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: proto.String("ListArticles lists Wiki articles.\n\nSupports pagination."), Path: []int32{6, 0, 2, 0}, Span: []int32{1, 0, 1, 1}, LeadingComments: new("ListArticles lists Wiki articles.\n\nSupports pagination."),
}}}, }}},
}, protoregistry.GlobalFiles) }, protoregistry.GlobalFiles)
if err != nil { if err != nil {

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
"net/url"
"strings" "strings"
"git.dev.alexdunmow.com/block/pluginsdk/blocks" "git.dev.alexdunmow.com/block/pluginsdk/blocks"
@ -350,7 +351,7 @@ func renderBlock(ctx context.Context, block map[string]any) string {
} }
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt)) img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
if link != "" { if link = safeLinkURL(link); link != "" {
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img) img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
} }
// The positioning wrapper only exists when a chip is rendered, so // The positioning wrapper only exists when a chip is rendered, so
@ -424,8 +425,8 @@ func renderBlock(ctx context.Context, block map[string]any) string {
name = url name = url
} }
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`) sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
if url != "" { if href := safeLinkURL(url); href != "" {
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url)) fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(href))
sb.WriteString(html.EscapeString(name)) sb.WriteString(html.EscapeString(name))
sb.WriteString("</a>") sb.WriteString("</a>")
} else { } else {
@ -627,6 +628,7 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
case "link": case "link":
href, _ := itemMap["href"].(string) href, _ := itemMap["href"].(string)
href = safeLinkURL(href)
linkContent := inlineContentFromRaw(itemMap["content"]) linkContent := inlineContentFromRaw(itemMap["content"])
if href == "" { if href == "" {
sb.WriteString(renderInlineContent(linkContent, insideLink)) sb.WriteString(renderInlineContent(linkContent, insideLink))
@ -656,6 +658,30 @@ func renderInlineContent(content []map[string]any, insideLink bool) string {
return sb.String() return sb.String()
} }
// safeLinkURL accepts ordinary web, email, telephone, and relative links while
// rejecting active-content schemes such as javascript: and data:. Escaping a
// URL protects the HTML attribute boundary, but it does not make an unsafe URL
// scheme safe to navigate to.
func safeLinkURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" {
if err != nil {
return ""
}
return trimmed
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https", "mailto", "tel":
return trimmed
default:
return ""
}
}
// imageChipClass matches the page-builder image block's attribution chip // imageChipClass matches the page-builder image block's attribution chip
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style // (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
// blog and page credits identically. // blog and page credits identically.
@ -700,8 +726,9 @@ func writeAttributionLink(b *strings.Builder, href, label string) {
// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL. // Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
// Closing parens, brackets and braces are kept inside the URL only when // Closing parens, brackets and braces are kept inside the URL only when
// balanced with an opener inside the URL itself — so // balanced with an opener inside the URL itself — so
// "(see https://example.com)" links only "https://example.com" //
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren. // "(see https://example.com)" links only "https://example.com"
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
func autolinkText(text string) string { func autolinkText(text string) string {
const scheme = "https://" const scheme = "https://"
var sb strings.Builder var sb strings.Builder

View File

@ -183,6 +183,27 @@ func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) {
} }
} }
func TestBlockNoteToHTML_RejectsActiveContentExplicitLink(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "paragraph",
"content": []any{
map[string]any{
"type": "link",
"href": " javascript:alert(1) ",
"content": []any{map[string]any{"type": "text", "text": "read this"}},
},
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if html != "<p class=\"my-4\">read this</p>\n" {
t.Fatalf("unsafe link should render as plain text: %q", html)
}
}
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) { func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) {
doc := map[string]any{ doc := map[string]any{
"blocks": []any{ "blocks": []any{

View File

@ -236,6 +236,28 @@ func TestFileBlock(t *testing.T) {
} }
} }
func TestFileBlockRejectsActiveContentURL(t *testing.T) {
doc := map[string]any{
"blocks": []any{
map[string]any{
"type": "file",
"props": map[string]any{
"url": "javascript:alert(document.domain)",
"name": "Unsafe link",
},
},
},
}
html := BlockNoteToHTML(context.Background(), doc)
if strings.Contains(html, "href=") || strings.Contains(html, "javascript:") {
t.Fatalf("unsafe file URL rendered as a link: %s", html)
}
if !strings.Contains(html, "Unsafe link") {
t.Fatalf("file label should remain visible as plain text: %s", html)
}
}
func TestFileBlockNoName(t *testing.T) { func TestFileBlockNoName(t *testing.T) {
doc := map[string]any{ doc := map[string]any{
"blocks": []any{ "blocks": []any{
@ -708,6 +730,18 @@ func TestImageBlockAltAndLink(t *testing.T) {
} }
} }
func TestImageBlockRejectsActiveContentLink(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "alt": "Sunset", "link": "JaVaScRiPt:alert(1)",
}))
if strings.Contains(html, "<a ") || strings.Contains(strings.ToLower(html), "javascript:") {
t.Fatalf("unsafe image link rendered: %s", html)
}
if !strings.Contains(html, `<img src="/media/x.webp"`) {
t.Fatalf("image should still render without its unsafe link: %s", html)
}
}
func TestImageBlockWidthAndAlign(t *testing.T) { func TestImageBlockWidthAndAlign(t *testing.T) {
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{ html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
"url": "/media/x.webp", "width": "medium", "align": "right", "url": "/media/x.webp", "width": "medium", "align": "right",