From 39427c40e77f295c4a6e4e9bc51257cf4cd8deb8 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Wed, 8 Jul 2026 02:00:29 +0800 Subject: [PATCH] feat(egress): http.request capability + allowed_hosts manifest (ADR 0023) Host-mediated outbound HTTP for wasm plugins: guest http.RoundTripper over the http.request capability; the host performs the request under policy. New egress package defines the host-pattern grammar (exact or multi-label wildcard, public-suffix rejected), matching, resource bounds, and the ErrDenied sentinel (ABI_ERROR_CODE_EGRESS_DENIED). plugin.mod gains first-class AllowedHosts + MaxResponseMB; manifest gains allowed_hosts=34, max_response_mb=35. CoreServices.OutboundHTTP carries the transport. Golden roundtrip + denial-mapping tests. Co-Authored-By: Claude Fable 5 --- abi/proto/v1/capability.proto | 32 +++ abi/proto/v1/invoke.proto | 6 + abi/proto/v1/manifest.proto | 14 ++ abi/v1/capability.pb.go | 222 ++++++++++++++++-- abi/v1/invoke.pb.go | 13 +- abi/v1/manifest.pb.go | 34 ++- egress/egress.go | 183 +++++++++++++++ egress/egress_test.go | 67 ++++++ go.mod | 6 +- go.sum | 16 +- plugin/deps.go | 8 + plugin/mod.go | 12 + plugin/wasmguest/caps/caps_roundtrip_test.go | 48 ++++ plugin/wasmguest/caps/coreservices.go | 1 + plugin/wasmguest/caps/net.go | 87 +++++++ .../caps/testdata/golden/http_request_req.pb | 4 + .../caps/testdata/golden/http_request_resp.pb | 3 + 17 files changed, 722 insertions(+), 34 deletions(-) create mode 100644 egress/egress.go create mode 100644 egress/egress_test.go create mode 100644 plugin/wasmguest/caps/net.go create mode 100644 plugin/wasmguest/caps/testdata/golden/http_request_req.pb create mode 100644 plugin/wasmguest/caps/testdata/golden/http_request_resp.pb diff --git a/abi/proto/v1/capability.proto b/abi/proto/v1/capability.proto index 1905b47..3087533 100644 --- a/abi/proto/v1/capability.proto +++ b/abi/proto/v1/capability.proto @@ -25,6 +25,7 @@ syntax = "proto3"; package abi.v1; import "google/protobuf/timestamp.proto"; +import "v1/http.proto"; import "v1/invoke.proto"; option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1"; @@ -854,3 +855,34 @@ message BridgeInvokeRequest { message BridgeInvokeResponse { bytes payload = 1; } + +// --- http.request (egress.Client host-mediated outbound HTTP, ADR 0023) --- + +// HttpRequestRequest asks the host to perform one outbound HTTPS request on +// the plugin's behalf. The host enforces the plugin's egress grant (host +// patterns + response cap), the platform denylist, the SSRF guard, and the +// redirect policy before any bytes leave — the guest never touches a socket. +// Names differ from the HANDLE_HTTP hook pair (HttpRequest/HttpResponse in +// http.proto), which is inbound traffic INTO the guest mux. +message HttpRequestRequest { + string method = 1; + // Absolute https URL (scheme required; http is refused by policy). + string url = 2; + map headers = 3; + bytes body = 4; + // Optional guest-side deadline. It may only LOWER the host's per-fetch + // ceiling, never raise it; zero means the host ceiling applies. + uint32 timeout_ms = 5; +} + +// HttpRequestResponse is the fully buffered upstream response. Bodies larger +// than the granted cap fail the call (EGRESS_DENIED is policy; oversize is +// INTERNAL with a too-large message) — never a silent truncation. +message HttpRequestResponse { + int32 status = 1; + map headers = 2; + bytes body = 3; + // URL that produced the response after any (allowlist-validated) + // redirects. + string final_url = 4; +} diff --git a/abi/proto/v1/invoke.proto b/abi/proto/v1/invoke.proto index 9e06563..02095b9 100644 --- a/abi/proto/v1/invoke.proto +++ b/abi/proto/v1/invoke.proto @@ -101,6 +101,12 @@ enum AbiErrorCode { // the cms dbexec side (adopted separately) and mapped guest-side to // bnwasm.ErrTxExpired. ABI_ERROR_CODE_TX_EXPIRED = 6; + // An http.request egress call was refused by policy (host not granted, + // grant revoked, platform-denylisted, or an off-allowlist redirect). + // message names the offending host. Mapped guest-side to + // egress.ErrDenied — deliberately distinct from a network failure + // (ADR 0023, cms repo). + ABI_ERROR_CODE_EGRESS_DENIED = 7; } // AbiError is the structured error carried by InvokeResponse and diff --git a/abi/proto/v1/manifest.proto b/abi/proto/v1/manifest.proto index a8a8cdf..01e2726 100644 --- a/abi/proto/v1/manifest.proto +++ b/abi/proto/v1/manifest.proto @@ -125,6 +125,20 @@ message PluginManifest { // to describe): `ninja plugin build` synthesizes the manifest from // plugin.mod + the artifact's declarative files. bool codeless = 33; + + // allowed_hosts declares the egress host patterns the plugin needs for + // http.request (exact hostname or left-anchored multi-label wildcard, + // e.g. "*.cal.com"; https/443 only unless an entry names an explicit + // port). Like data_dir, its source of truth is plugin.mod — the packer + // stamps it — because it lives outside the guest code. The host refuses + // to load the plugin until an admin grants the full declared set + // (ADR 0023, cms repo). Empty means no egress. + repeated string allowed_hosts = 34; + + // max_response_mb requests a larger per-request http.request response cap + // than the 10 MB default. Rides the same admin grant as allowed_hosts. + // Zero means the default. + uint32 max_response_mb = 35; } // Dependency mirrors plugin.Dependency. diff --git a/abi/v1/capability.pb.go b/abi/v1/capability.pb.go index a4a9097..87b5be9 100644 --- a/abi/v1/capability.pb.go +++ b/abi/v1/capability.pb.go @@ -7469,11 +7469,169 @@ func (x *BridgeInvokeResponse) GetPayload() []byte { return nil } +// HttpRequestRequest asks the host to perform one outbound HTTPS request on +// the plugin's behalf. The host enforces the plugin's egress grant (host +// patterns + response cap), the platform denylist, the SSRF guard, and the +// redirect policy before any bytes leave — the guest never touches a socket. +// Names differ from the HANDLE_HTTP hook pair (HttpRequest/HttpResponse in +// http.proto), which is inbound traffic INTO the guest mux. +type HttpRequestRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + // Absolute https URL (scheme required; http is refused by policy). + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` + // Optional guest-side deadline. It may only LOWER the host's per-fetch + // ceiling, never raise it; zero means the host ceiling applies. + TimeoutMs uint32 `protobuf:"varint,5,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HttpRequestRequest) Reset() { + *x = HttpRequestRequest{} + mi := &file_v1_capability_proto_msgTypes[141] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HttpRequestRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HttpRequestRequest) ProtoMessage() {} + +func (x *HttpRequestRequest) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[141] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HttpRequestRequest.ProtoReflect.Descriptor instead. +func (*HttpRequestRequest) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{141} +} + +func (x *HttpRequestRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *HttpRequestRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *HttpRequestRequest) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpRequestRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *HttpRequestRequest) GetTimeoutMs() uint32 { + if x != nil { + return x.TimeoutMs + } + return 0 +} + +// HttpRequestResponse is the fully buffered upstream response. Bodies larger +// than the granted cap fail the call (EGRESS_DENIED is policy; oversize is +// INTERNAL with a too-large message) — never a silent truncation. +type HttpRequestResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + Headers map[string]*HeaderValues `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Body []byte `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + // URL that produced the response after any (allowlist-validated) + // redirects. + FinalUrl string `protobuf:"bytes,4,opt,name=final_url,json=finalUrl,proto3" json:"final_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HttpRequestResponse) Reset() { + *x = HttpRequestResponse{} + mi := &file_v1_capability_proto_msgTypes[142] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HttpRequestResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HttpRequestResponse) ProtoMessage() {} + +func (x *HttpRequestResponse) ProtoReflect() protoreflect.Message { + mi := &file_v1_capability_proto_msgTypes[142] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HttpRequestResponse.ProtoReflect.Descriptor instead. +func (*HttpRequestResponse) Descriptor() ([]byte, []int) { + return file_v1_capability_proto_rawDescGZIP(), []int{142} +} + +func (x *HttpRequestResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *HttpRequestResponse) GetHeaders() map[string]*HeaderValues { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpRequestResponse) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *HttpRequestResponse) GetFinalUrl() string { + if x != nil { + return x.FinalUrl + } + return "" +} + var File_v1_capability_proto protoreflect.FileDescriptor const file_v1_capability_proto_rawDesc = "" + "\n" + - "\x13v1/capability.proto\x12\x06abi.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x0fv1/invoke.proto\"C\n" + + "\x13v1/capability.proto\x12\x06abi.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\rv1/http.proto\x1a\x0fv1/invoke.proto\"C\n" + "\x0fHostCallRequest\x12\x16\n" + "\x06method\x18\x01 \x01(\tR\x06method\x12\x18\n" + "\apayload\x18\x02 \x01(\fR\apayload\"T\n" + @@ -7967,7 +8125,25 @@ const file_v1_capability_proto_rawDesc = "" + "\x06method\x18\x03 \x01(\tR\x06method\x12\x18\n" + "\apayload\x18\x04 \x01(\fR\apayload\"0\n" + "\x14BridgeInvokeResponse\x12\x18\n" + - "\apayload\x18\x01 \x01(\fR\apayloadB5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" + "\apayload\x18\x01 \x01(\fR\apayload\"\x86\x02\n" + + "\x12HttpRequestRequest\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\x12A\n" + + "\aheaders\x18\x03 \x03(\v2'.abi.v1.HttpRequestRequest.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\x04 \x01(\fR\x04body\x12\x1d\n" + + "\n" + + "timeout_ms\x18\x05 \x01(\rR\ttimeoutMs\x1aP\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01\"\xf4\x01\n" + + "\x13HttpRequestResponse\x12\x16\n" + + "\x06status\x18\x01 \x01(\x05R\x06status\x12B\n" + + "\aheaders\x18\x02 \x03(\v2(.abi.v1.HttpRequestResponse.HeadersEntryR\aheaders\x12\x12\n" + + "\x04body\x18\x03 \x01(\fR\x04body\x12\x1b\n" + + "\tfinal_url\x18\x04 \x01(\tR\bfinalUrl\x1aP\n" + + "\fHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12*\n" + + "\x05value\x18\x02 \x01(\v2\x14.abi.v1.HeaderValuesR\x05value:\x028\x01B5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" var ( file_v1_capability_proto_rawDescOnce sync.Once @@ -7981,7 +8157,7 @@ func file_v1_capability_proto_rawDescGZIP() []byte { return file_v1_capability_proto_rawDescData } -var file_v1_capability_proto_msgTypes = make([]protoimpl.MessageInfo, 143) +var file_v1_capability_proto_msgTypes = make([]protoimpl.MessageInfo, 147) var file_v1_capability_proto_goTypes = []any{ (*HostCallRequest)(nil), // 0: abi.v1.HostCallRequest (*HostCallResponse)(nil), // 1: abi.v1.HostCallResponse @@ -8124,18 +8300,23 @@ var file_v1_capability_proto_goTypes = []any{ (*JobsProgressResponse)(nil), // 138: abi.v1.JobsProgressResponse (*BridgeInvokeRequest)(nil), // 139: abi.v1.BridgeInvokeRequest (*BridgeInvokeResponse)(nil), // 140: abi.v1.BridgeInvokeResponse - nil, // 141: abi.v1.AuthorProfile.SocialLinksEntry - nil, // 142: abi.v1.RagResult.MetadataEntry - (*AbiError)(nil), // 143: abi.v1.AbiError - (*timestamppb.Timestamp)(nil), // 144: google.protobuf.Timestamp + (*HttpRequestRequest)(nil), // 141: abi.v1.HttpRequestRequest + (*HttpRequestResponse)(nil), // 142: abi.v1.HttpRequestResponse + nil, // 143: abi.v1.AuthorProfile.SocialLinksEntry + nil, // 144: abi.v1.RagResult.MetadataEntry + nil, // 145: abi.v1.HttpRequestRequest.HeadersEntry + nil, // 146: abi.v1.HttpRequestResponse.HeadersEntry + (*AbiError)(nil), // 147: abi.v1.AbiError + (*timestamppb.Timestamp)(nil), // 148: google.protobuf.Timestamp + (*HeaderValues)(nil), // 149: abi.v1.HeaderValues } var file_v1_capability_proto_depIdxs = []int32{ - 143, // 0: abi.v1.HostCallResponse.error:type_name -> abi.v1.AbiError + 147, // 0: abi.v1.HostCallResponse.error:type_name -> abi.v1.AbiError 4, // 1: abi.v1.ContentGetAuthorProfileResponse.author:type_name -> abi.v1.AuthorProfile - 141, // 2: abi.v1.AuthorProfile.social_links:type_name -> abi.v1.AuthorProfile.SocialLinksEntry + 143, // 2: abi.v1.AuthorProfile.social_links:type_name -> abi.v1.AuthorProfile.SocialLinksEntry 7, // 3: abi.v1.ContentGetPageResponse.page:type_name -> abi.v1.PageInfo 12, // 4: abi.v1.ContentGetPostResponse.post:type_name -> abi.v1.PostInfo - 144, // 5: abi.v1.PostInfo.published_at:type_name -> google.protobuf.Timestamp + 148, // 5: abi.v1.PostInfo.published_at:type_name -> google.protobuf.Timestamp 12, // 6: abi.v1.ContentListPostsResponse.posts:type_name -> abi.v1.PostInfo 33, // 7: abi.v1.GatingEvaluateAccessRequest.rule:type_name -> abi.v1.AccessRule 34, // 8: abi.v1.GatingEvaluateAccessResponse.result:type_name -> abi.v1.AccessResult @@ -8149,17 +8330,21 @@ var file_v1_capability_proto_depIdxs = []int32{ 60, // 16: abi.v1.SubscriptionsGetTierBySlugResponse.tier:type_name -> abi.v1.Tier 60, // 17: abi.v1.SubscriptionsListTiersResponse.tiers:type_name -> abi.v1.Tier 65, // 18: abi.v1.SubscriptionsListActivePlansResponse.plans:type_name -> abi.v1.Plan - 144, // 19: abi.v1.Plan.created_at:type_name -> google.protobuf.Timestamp + 148, // 19: abi.v1.Plan.created_at:type_name -> google.protobuf.Timestamp 88, // 20: abi.v1.RagQueryResponse.results:type_name -> abi.v1.RagResult - 142, // 21: abi.v1.RagResult.metadata:type_name -> abi.v1.RagResult.MetadataEntry + 144, // 21: abi.v1.RagResult.metadata:type_name -> abi.v1.RagResult.MetadataEntry 97, // 22: abi.v1.ContentSetPageBlocksRequest.blocks:type_name -> abi.v1.PageBlock 97, // 23: abi.v1.PageSeed.blocks:type_name -> abi.v1.PageBlock 112, // 24: abi.v1.ProvisionerEnsurePageRequest.page:type_name -> abi.v1.PageSeed - 25, // [25:25] is the sub-list for method output_type - 25, // [25:25] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 145, // 25: abi.v1.HttpRequestRequest.headers:type_name -> abi.v1.HttpRequestRequest.HeadersEntry + 146, // 26: abi.v1.HttpRequestResponse.headers:type_name -> abi.v1.HttpRequestResponse.HeadersEntry + 149, // 27: abi.v1.HttpRequestRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues + 149, // 28: abi.v1.HttpRequestResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues + 29, // [29:29] is the sub-list for method output_type + 29, // [29:29] is the sub-list for method input_type + 29, // [29:29] is the sub-list for extension type_name + 29, // [29:29] is the sub-list for extension extendee + 0, // [0:29] is the sub-list for field type_name } func init() { file_v1_capability_proto_init() } @@ -8167,6 +8352,7 @@ func file_v1_capability_proto_init() { if File_v1_capability_proto != nil { return } + file_v1_http_proto_init() file_v1_invoke_proto_init() file_v1_capability_proto_msgTypes[44].OneofWrappers = []any{} file_v1_capability_proto_msgTypes[97].OneofWrappers = []any{} @@ -8178,7 +8364,7 @@ func file_v1_capability_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_capability_proto_rawDesc), len(file_v1_capability_proto_rawDesc)), NumEnums: 0, - NumMessages: 143, + NumMessages: 147, NumExtensions: 0, NumServices: 0, }, diff --git a/abi/v1/invoke.pb.go b/abi/v1/invoke.pb.go index 6f51b2a..97fae10 100644 --- a/abi/v1/invoke.pb.go +++ b/abi/v1/invoke.pb.go @@ -168,6 +168,12 @@ const ( // the cms dbexec side (adopted separately) and mapped guest-side to // bnwasm.ErrTxExpired. AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED AbiErrorCode = 6 + // An http.request egress call was refused by policy (host not granted, + // grant revoked, platform-denylisted, or an off-allowlist redirect). + // message names the offending host. Mapped guest-side to + // egress.ErrDenied — deliberately distinct from a network failure + // (ADR 0023, cms repo). + AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED AbiErrorCode = 7 ) // Enum value maps for AbiErrorCode. @@ -180,6 +186,7 @@ var ( 4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED", 5: "ABI_ERROR_CODE_PERMISSION_DENIED", 6: "ABI_ERROR_CODE_TX_EXPIRED", + 7: "ABI_ERROR_CODE_EGRESS_DENIED", } AbiErrorCode_value = map[string]int32{ "ABI_ERROR_CODE_UNSPECIFIED": 0, @@ -189,6 +196,7 @@ var ( "ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4, "ABI_ERROR_CODE_PERMISSION_DENIED": 5, "ABI_ERROR_CODE_TX_EXPIRED": 6, + "ABI_ERROR_CODE_EGRESS_DENIED": 7, } ) @@ -1790,7 +1798,7 @@ const file_v1_invoke_proto_rawDesc = "" + "\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" + "\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" + "\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" + - "\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\xf3\x01\n" + + "\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\x95\x02\n" + "\fAbiErrorCode\x12\x1e\n" + "\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" + @@ -1798,7 +1806,8 @@ const file_v1_invoke_proto_rawDesc = "" + "\x1cABI_ERROR_CODE_UNIMPLEMENTED\x10\x03\x12$\n" + " ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" + " ABI_ERROR_CODE_PERMISSION_DENIED\x10\x05\x12\x1d\n" + - "\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06B5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" + "\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06\x12 \n" + + "\x1cABI_ERROR_CODE_EGRESS_DENIED\x10\aB5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3" var ( file_v1_invoke_proto_rawDescOnce sync.Once diff --git a/abi/v1/manifest.pb.go b/abi/v1/manifest.pb.go index 9549e7f..1f5e79a 100644 --- a/abi/v1/manifest.pb.go +++ b/abi/v1/manifest.pb.go @@ -122,7 +122,19 @@ type PluginManifest struct { // both reject the combination. Not produced by DESCRIBE (there is no guest // to describe): `ninja plugin build` synthesizes the manifest from // plugin.mod + the artifact's declarative files. - Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"` + Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"` + // allowed_hosts declares the egress host patterns the plugin needs for + // http.request (exact hostname or left-anchored multi-label wildcard, + // e.g. "*.cal.com"; https/443 only unless an entry names an explicit + // port). Like data_dir, its source of truth is plugin.mod — the packer + // stamps it — because it lives outside the guest code. The host refuses + // to load the plugin until an admin grants the full declared set + // (ADR 0023, cms repo). Empty means no egress. + AllowedHosts []string `protobuf:"bytes,34,rep,name=allowed_hosts,json=allowedHosts,proto3" json:"allowed_hosts,omitempty"` + // max_response_mb requests a larger per-request http.request response cap + // than the 10 MB default. Rides the same admin grant as allowed_hosts. + // Zero means the default. + MaxResponseMb uint32 `protobuf:"varint,35,opt,name=max_response_mb,json=maxResponseMb,proto3" json:"max_response_mb,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -388,6 +400,20 @@ func (x *PluginManifest) GetCodeless() bool { return false } +func (x *PluginManifest) GetAllowedHosts() []string { + if x != nil { + return x.AllowedHosts + } + return nil +} + +func (x *PluginManifest) GetMaxResponseMb() uint32 { + if x != nil { + return x.MaxResponseMb + } + return 0 +} + // Dependency mirrors plugin.Dependency. type Dependency struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1300,7 +1326,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor const file_v1_manifest_proto_rawDesc = "" + "\n" + - "\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" + + "\x11v1/manifest.proto\x12\x06abi.v1\"\xdb\r\n" + "\x0ePluginManifest\x12\x1f\n" + "\vabi_version\x18\x01 \x01(\rR\n" + "abiVersion\x12\x12\n" + @@ -1338,7 +1364,9 @@ const file_v1_manifest_proto_rawDesc = "" + "\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" + "\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" + "\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" + - "\bcodeless\x18! \x01(\bR\bcodeless\x1aB\n" + + "\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" + + "\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" + + "\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x1aB\n" + "\x14RbacMethodRolesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" + diff --git a/egress/egress.go b/egress/egress.go new file mode 100644 index 0000000..abf2c4a --- /dev/null +++ b/egress/egress.go @@ -0,0 +1,183 @@ +// Package egress defines the wire-independent policy for host-mediated plugin +// HTTP egress (ADR 0023, cms repo): the host-pattern grammar and matching, the +// default resource bounds, and the ErrDenied sentinel. Both the guest SDK +// (validating plugin.mod at build) and the cms host (enforcing grants at +// fetch) import this package so the pattern semantics have exactly one +// definition. +package egress + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// ErrDenied marks an egress request refused by policy — host not granted, +// grant revoked, platform-denylisted, or an off-allowlist redirect. It is +// deliberately distinct from a network failure: a plugin can catch it and +// degrade gracefully. It maps to ABI_ERROR_CODE_EGRESS_DENIED across the wire. +var ErrDenied = errors.New("egress denied") + +const ( + // DefaultMaxResponseBytes is the per-request response cap when a plugin + // declares no larger one. Oversize fails the request, never truncates. + DefaultMaxResponseBytes = 10 << 20 + // MaxRequestBytes caps the request body a guest may marshal across the + // host_call boundary. + MaxRequestBytes = 10 << 20 + // DefaultPort is the port a host pattern authorizes when it names none. + DefaultPort = 443 +) + +// Pattern is one parsed allowed_hosts entry: an exact host or a left-anchored +// multi-label wildcard, optionally pinned to a non-default port. +type Pattern struct { + // host is the bare hostname (wildcard: the base domain after "*."). + host string + wildcard bool + port int +} + +// ParsePattern parses and validates one allowed_hosts entry. It rejects bare +// "*", public-suffix wildcards ("*.com", "*.co.uk"), embedded schemes/paths, +// and malformed ports. A leading "*." marks a multi-label wildcard whose base +// must be a registrable domain (≥2 labels and not itself a public suffix). +func ParsePattern(raw string) (Pattern, error) { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + return Pattern{}, fmt.Errorf("empty host pattern") + } + if strings.ContainsAny(s, "/:@") && !strings.Contains(s, ":") { + return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw) + } + if strings.Contains(s, "://") || strings.ContainsAny(s, "/@") { + return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw) + } + + port := DefaultPort + host := s + if h, p, ok := splitHostPort(s); ok { + host = h + parsed, err := strconv.Atoi(p) + if err != nil || parsed < 1 || parsed > 65535 { + return Pattern{}, fmt.Errorf("host pattern %q has an invalid port", raw) + } + port = parsed + } + + wildcard := false + if strings.HasPrefix(host, "*.") { + wildcard = true + host = strings.TrimPrefix(host, "*.") + if host == "" || strings.Contains(host, "*") { + return Pattern{}, fmt.Errorf("host pattern %q: only a single leading %q wildcard is allowed", raw, "*.") + } + if strings.Count(host, ".") < 1 { + return Pattern{}, fmt.Errorf("host pattern %q: wildcard base must be a registrable domain (e.g. *.cal.com)", raw) + } + if suffix, _ := publicsuffix.PublicSuffix(host); suffix == host { + return Pattern{}, fmt.Errorf("host pattern %q: cannot wildcard a public suffix", raw) + } + } + if host == "" || strings.Contains(host, "*") { + return Pattern{}, fmt.Errorf("host pattern %q is malformed", raw) + } + if !validHostname(host) { + return Pattern{}, fmt.Errorf("host pattern %q is not a valid hostname", raw) + } + return Pattern{host: host, wildcard: wildcard, port: port}, nil +} + +// matches reports whether this pattern authorizes host:port. +func (p Pattern) matches(host string, port int) bool { + if port != p.port { + return false + } + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if p.wildcard { + return strings.HasSuffix(host, "."+p.host) + } + return host == p.host +} + +// Allowlist is a plugin's parsed egress grant. +type Allowlist []Pattern + +// ParseAllowlist parses every entry, failing on the first invalid one. +func ParseAllowlist(raw []string) (Allowlist, error) { + out := make(Allowlist, 0, len(raw)) + for _, r := range raw { + p, err := ParsePattern(r) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +// Allows returns nil if u is authorized by this allowlist, else an ErrDenied. +// It enforces the https-only scheme rule and the port pinning; the caller +// still applies the SSRF IP guard and platform denylist. +func (a Allowlist) Allows(u *url.URL) error { + if u == nil { + return fmt.Errorf("%w: nil URL", ErrDenied) + } + if strings.ToLower(u.Scheme) != "https" { + return fmt.Errorf("%w: %q is not https", ErrDenied, u.Scheme) + } + host := u.Hostname() + if host == "" { + return fmt.Errorf("%w: missing host", ErrDenied) + } + port := DefaultPort + if p := u.Port(); p != "" { + parsed, err := strconv.Atoi(p) + if err != nil { + return fmt.Errorf("%w: invalid port", ErrDenied) + } + port = parsed + } + for _, pat := range a { + if pat.matches(host, port) { + return nil + } + } + return fmt.Errorf("%w: host %q not in allowlist", ErrDenied, u.Host) +} + +func splitHostPort(s string) (host, port string, ok bool) { + i := strings.LastIndex(s, ":") + if i < 0 { + return "", "", false + } + // Reject bracketed IPv6 / multiple colons — patterns are hostnames. + if strings.Contains(s[:i], ":") { + return "", "", false + } + return s[:i], s[i+1:], true +} + +func validHostname(h string) bool { + if len(h) > 253 { + return false + } + for _, label := range strings.Split(h, ".") { + if label == "" || len(label) > 63 { + return false + } + for _, r := range label { + if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' { + return false + } + } + if label[0] == '-' || label[len(label)-1] == '-' { + return false + } + } + return true +} diff --git a/egress/egress_test.go b/egress/egress_test.go new file mode 100644 index 0000000..a583390 --- /dev/null +++ b/egress/egress_test.go @@ -0,0 +1,67 @@ +package egress + +import ( + "errors" + "net/url" + "testing" +) + +func TestParsePatternRejects(t *testing.T) { + for _, raw := range []string{ + "", "*", "*.com", "*.co.uk", "*.uk", + "https://api.cal.com", "api.cal.com/path", "user@api.cal.com", + "*.*.cal.com", "api.cal.com:0", "api.cal.com:99999", + "*.", "-bad.com", "bad-.com", + } { + if _, err := ParsePattern(raw); err == nil { + t.Errorf("ParsePattern(%q) = nil error, want rejection", raw) + } + } +} + +func TestParsePatternAccepts(t *testing.T) { + for _, raw := range []string{ + "api.cal.com", "*.cal.com", "example.com", + "api.example.com:8443", "sub.deep.example.org", + } { + if _, err := ParsePattern(raw); err != nil { + t.Errorf("ParsePattern(%q) = %v, want accept", raw, err) + } + } +} + +func TestAllowlistAllows(t *testing.T) { + al, err := ParseAllowlist([]string{"api.cal.com", "*.example.org", "api.svc.com:8443"}) + if err != nil { + t.Fatalf("ParseAllowlist: %v", err) + } + tests := []struct { + url string + allow bool + }{ + {"https://api.cal.com/v1/slots", true}, + {"https://api.cal.com:443/v1/slots", true}, + {"https://cal.com/", false}, // apex not matched by exact api.cal.com + {"http://api.cal.com/", false}, // http refused + {"https://api.cal.com:8443/", false}, // wrong port + {"https://a.example.org/", true}, // wildcard depth 1 + {"https://a.b.c.example.org/", true}, // wildcard multi-label + {"https://example.org/", false}, // wildcard excludes apex + {"https://evilexample.org/", false}, // suffix must be dot-anchored + {"https://api.svc.com:8443/", true}, // explicit port + {"https://api.svc.com/", false}, // default port not authorized + } + for _, tt := range tests { + u, _ := url.Parse(tt.url) + err := al.Allows(u) + if tt.allow && err != nil { + t.Errorf("Allows(%q) = %v, want allow", tt.url, err) + } + if !tt.allow && err == nil { + t.Errorf("Allows(%q) = nil, want deny", tt.url) + } + if !tt.allow && err != nil && !errors.Is(err, ErrDenied) { + t.Errorf("Allows(%q) denial not ErrDenied: %v", tt.url, err) + } + } +} diff --git a/go.mod b/go.mod index 935fd8b..deab9b4 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,19 @@ go 1.26.4 require ( connectrpc.com/connect v1.20.0 - git.dev.alexdunmow.com/block/ninjatpl v1.0.2 github.com/BurntSushi/toml v1.6.0 github.com/a-h/templ v0.3.1020 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/tetratelabs/wazero v1.12.0 golang.org/x/mod v0.37.0 + golang.org/x/net v0.56.0 google.golang.org/protobuf v1.36.11 ) require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 89849f8..5376bc1 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -git.dev.alexdunmow.com/block/ninjatpl v1.0.2 h1:KQS90HNW35PSzvwDZ3Z9OOwX3OSjX0x62w0AtOYps8s= -git.dev.alexdunmow.com/block/ninjatpl v1.0.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= @@ -32,12 +30,14 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/plugin/deps.go b/plugin/deps.go index 6129fee..3561f69 100644 --- a/plugin/deps.go +++ b/plugin/deps.go @@ -2,6 +2,7 @@ package plugin import ( "context" + "net/http" "connectrpc.com/connect" "git.dev.alexdunmow.com/block/pluginsdk/ai" @@ -52,6 +53,13 @@ type CoreServices struct { // Plugin interop Bridge PluginBridge + // OutboundHTTP is the host-mediated egress transport (ADR 0023, cms repo). + // Plugins make outbound requests through &http.Client{Transport: + // deps.OutboundHTTP}; the host enforces the plugin's allowed_hosts grant, + // the SSRF guard, and the platform denylist. Nil when the plugin declared + // no allowed_hosts — a request through it fails closed. + OutboundHTTP http.RoundTripper + // Core RPC services — pre-built bindings for CMS-provided services CoreServiceBindings CoreServiceBindings ReviewSubmitter ReviewSubmitter diff --git a/plugin/mod.go b/plugin/mod.go index 9a4e035..6341f4a 100644 --- a/plugin/mod.go +++ b/plugin/mod.go @@ -53,6 +53,18 @@ type ModPlugin struct { // packed manifest (PluginManifest.data_dir); the .bnp reader also reads it // straight from plugin.mod as a fallback. OFF by default. DataDir bool `toml:"data_dir,omitempty"` + // AllowedHosts declares the egress host patterns the plugin needs for + // http.request (ADR 0023, cms repo): an exact hostname or a left-anchored + // multi-label wildcard (e.g. "*.cal.com"), each https/443 unless the entry + // names an explicit port. First-class for the same reason as DataDir — the + // writeMod round-trip drops any key without a struct home. `ninja plugin + // build` validates the patterns and stamps them into the manifest + // (PluginManifest.allowed_hosts). Empty means no egress. + AllowedHosts []string `toml:"allowed_hosts,omitempty"` + // MaxResponseMB requests a larger per-request http.request response cap + // than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero + // means the default. + MaxResponseMB uint32 `toml:"max_response_mb,omitempty"` } type ModCompat struct { diff --git a/plugin/wasmguest/caps/caps_roundtrip_test.go b/plugin/wasmguest/caps/caps_roundtrip_test.go index 3f291b8..252027e 100644 --- a/plugin/wasmguest/caps/caps_roundtrip_test.go +++ b/plugin/wasmguest/caps/caps_roundtrip_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "flag" + "io" + "net/http" "os" "path/filepath" "strings" @@ -13,6 +15,7 @@ import ( abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1" "git.dev.alexdunmow.com/block/pluginsdk/ai" "git.dev.alexdunmow.com/block/pluginsdk/content" + "git.dev.alexdunmow.com/block/pluginsdk/egress" "git.dev.alexdunmow.com/block/pluginsdk/gating" "git.dev.alexdunmow.com/block/pluginsdk/plugin" "github.com/google/uuid" @@ -864,6 +867,35 @@ func TestCapabilityRoundTrip(t *testing.T) { } }, }, + // --- http.request (ADR 0023; RoundTripper, not a value-returning stub) --- + { + name: "http_request", wantMethod: "http.request", + resp: &abiv1.HttpRequestResponse{ + Status: 200, + Headers: map[string]*abiv1.HeaderValues{"Content-Type": {Values: []string{"application/json"}}}, + Body: []byte(`{"ok":true}`), + FinalUrl: "https://api.cal.com/v1/slots", + }, + run: func(t *testing.T, cs plugin.CoreServices) { + req, err := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer test") + res, err := cs.OutboundHTTP.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = res.Body.Close() }() + if res.StatusCode != 200 || res.Header.Get("Content-Type") != "application/json" { + t.Errorf("response = %+v", res) + } + body, _ := io.ReadAll(res.Body) + if string(body) != `{"ok":true}` { + t.Errorf("body = %q", body) + } + }, + }, // --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a // CoreServices stub, so the case drives the transport directly) --- { @@ -932,6 +964,22 @@ func TestErrorMappingDeadlineBecomesContextDeadline(t *testing.T) { } } +func TestEgressDeniedMapsToErrDenied(t *testing.T) { + f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED, msg: `host "evil.example" not in allowlist`}} + cs := NewCoreServices(f.call) + f.name = "http_request" // reuse the request golden for the compare + + req, _ := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil) + req.Header.Set("Authorization", "Bearer test") // match the http_request_req golden + _, err := cs.OutboundHTTP.RoundTrip(req) + if err == nil { + t.Fatal("want error") + } + if !errors.Is(err, egress.ErrDenied) { + t.Errorf("error %q does not wrap egress.ErrDenied", err) + } +} + func TestNilTransportFailsCleanly(t *testing.T) { cs := NewCoreServices(nil) _, err := cs.Content.GetPage(context.Background(), "about") diff --git a/plugin/wasmguest/caps/coreservices.go b/plugin/wasmguest/caps/coreservices.go index 4ef9034..1b02423 100644 --- a/plugin/wasmguest/caps/coreservices.go +++ b/plugin/wasmguest/caps/coreservices.go @@ -47,5 +47,6 @@ func NewCoreServices(call CallFunc) plugin.CoreServices { EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}}, RAGService: NewRAGStub(call), Provisioner: &provisionerStub{base{family: "provisioner", call: call}}, + OutboundHTTP: &httpStub{base{family: "http", call: call}}, } } diff --git a/plugin/wasmguest/caps/net.go b/plugin/wasmguest/caps/net.go new file mode 100644 index 0000000..82617fe --- /dev/null +++ b/plugin/wasmguest/caps/net.go @@ -0,0 +1,87 @@ +package caps + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "time" + + abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1" + "git.dev.alexdunmow.com/block/pluginsdk/egress" +) + +// httpStub implements http.RoundTripper over the http.request capability: it +// marshals the outbound *http.Request into an HttpRequestRequest, hands it to +// the host (which enforces the plugin's egress grant, the SSRF guard, and the +// platform denylist, then performs the request), and rebuilds the reply into +// an *http.Response. The guest never touches a socket. Plugins use it through +// an ordinary &http.Client{Transport: deps.OutboundHTTP}. +type httpStub struct{ base } + +var _ http.RoundTripper = (*httpStub)(nil) + +// MaxRequestBytes bounds the request body the guest will marshal across the +// boundary, matching the host's cap; larger fails before any host call. +func (s *httpStub) RoundTrip(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + defer func() { _ = req.Body.Close() }() + b, err := io.ReadAll(io.LimitReader(req.Body, egress.MaxRequestBytes+1)) + if err != nil { + return nil, fmt.Errorf("http.request: read body: %w", err) + } + if len(b) > egress.MaxRequestBytes { + return nil, fmt.Errorf("http.request: request body exceeds %d bytes", egress.MaxRequestBytes) + } + body = b + } + + out := &abiv1.HttpRequestRequest{ + Method: req.Method, + Url: req.URL.String(), + Headers: make(map[string]*abiv1.HeaderValues, len(req.Header)), + Body: body, + } + for name, values := range req.Header { + out.Headers[name] = &abiv1.HeaderValues{Values: values} + } + if dl, ok := req.Context().Deadline(); ok { + if ms := time.Until(dl).Milliseconds(); ms > 0 { + out.TimeoutMs = uint32(ms) + } + } + + resp := &abiv1.HttpRequestResponse{} + if err := s.invoke(req.Context(), "request", out, resp); err != nil { + if isEgressDenied(err) { + return nil, fmt.Errorf("%w: %s", egress.ErrDenied, err.Error()) + } + return nil, err + } + + header := make(http.Header, len(resp.GetHeaders())) + for name, hv := range resp.GetHeaders() { + for _, v := range hv.GetValues() { + header.Add(name, v) + } + } + return &http.Response{ + Status: http.StatusText(int(resp.GetStatus())), + StatusCode: int(resp.GetStatus()), + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: header, + Body: io.NopCloser(bytes.NewReader(resp.GetBody())), + ContentLength: int64(len(resp.GetBody())), + Request: req, + }, nil +} + +func isEgressDenied(err error) bool { + var coded abiCoded + return errors.As(err, &coded) && + coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED +} diff --git a/plugin/wasmguest/caps/testdata/golden/http_request_req.pb b/plugin/wasmguest/caps/testdata/golden/http_request_req.pb new file mode 100644 index 0000000..a93a2d9 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/http_request_req.pb @@ -0,0 +1,4 @@ + +GEThttps://api.cal.com/v1/slots + Authorization + Bearer test \ No newline at end of file diff --git a/plugin/wasmguest/caps/testdata/golden/http_request_resp.pb b/plugin/wasmguest/caps/testdata/golden/http_request_resp.pb new file mode 100644 index 0000000..71c8a03 --- /dev/null +++ b/plugin/wasmguest/caps/testdata/golden/http_request_resp.pb @@ -0,0 +1,3 @@ +È" + Content-Type +application/json {"ok":true}"https://api.cal.com/v1/slots \ No newline at end of file