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 <noreply@anthropic.com>
This commit is contained in:
parent
99e791b6e9
commit
39427c40e7
@ -25,6 +25,7 @@ syntax = "proto3";
|
|||||||
package abi.v1;
|
package abi.v1;
|
||||||
|
|
||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
import "v1/http.proto";
|
||||||
import "v1/invoke.proto";
|
import "v1/invoke.proto";
|
||||||
|
|
||||||
option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1";
|
option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1";
|
||||||
@ -854,3 +855,34 @@ message BridgeInvokeRequest {
|
|||||||
message BridgeInvokeResponse {
|
message BridgeInvokeResponse {
|
||||||
bytes payload = 1;
|
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<string, HeaderValues> 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<string, HeaderValues> headers = 2;
|
||||||
|
bytes body = 3;
|
||||||
|
// URL that produced the response after any (allowlist-validated)
|
||||||
|
// redirects.
|
||||||
|
string final_url = 4;
|
||||||
|
}
|
||||||
|
|||||||
@ -101,6 +101,12 @@ enum AbiErrorCode {
|
|||||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||||
// bnwasm.ErrTxExpired.
|
// bnwasm.ErrTxExpired.
|
||||||
ABI_ERROR_CODE_TX_EXPIRED = 6;
|
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
|
// AbiError is the structured error carried by InvokeResponse and
|
||||||
|
|||||||
@ -125,6 +125,20 @@ message PluginManifest {
|
|||||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||||
// plugin.mod + the artifact's declarative files.
|
// plugin.mod + the artifact's declarative files.
|
||||||
bool codeless = 33;
|
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.
|
// Dependency mirrors plugin.Dependency.
|
||||||
|
|||||||
@ -7469,11 +7469,169 @@ func (x *BridgeInvokeResponse) GetPayload() []byte {
|
|||||||
return nil
|
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
|
var File_v1_capability_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_v1_capability_proto_rawDesc = "" +
|
const file_v1_capability_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\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" +
|
"\x0fHostCallRequest\x12\x16\n" +
|
||||||
"\x06method\x18\x01 \x01(\tR\x06method\x12\x18\n" +
|
"\x06method\x18\x01 \x01(\tR\x06method\x12\x18\n" +
|
||||||
"\apayload\x18\x02 \x01(\fR\apayload\"T\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" +
|
"\x06method\x18\x03 \x01(\tR\x06method\x12\x18\n" +
|
||||||
"\apayload\x18\x04 \x01(\fR\apayload\"0\n" +
|
"\apayload\x18\x04 \x01(\fR\apayload\"0\n" +
|
||||||
"\x14BridgeInvokeResponse\x12\x18\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 (
|
var (
|
||||||
file_v1_capability_proto_rawDescOnce sync.Once
|
file_v1_capability_proto_rawDescOnce sync.Once
|
||||||
@ -7981,7 +8157,7 @@ func file_v1_capability_proto_rawDescGZIP() []byte {
|
|||||||
return file_v1_capability_proto_rawDescData
|
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{
|
var file_v1_capability_proto_goTypes = []any{
|
||||||
(*HostCallRequest)(nil), // 0: abi.v1.HostCallRequest
|
(*HostCallRequest)(nil), // 0: abi.v1.HostCallRequest
|
||||||
(*HostCallResponse)(nil), // 1: abi.v1.HostCallResponse
|
(*HostCallResponse)(nil), // 1: abi.v1.HostCallResponse
|
||||||
@ -8124,18 +8300,23 @@ var file_v1_capability_proto_goTypes = []any{
|
|||||||
(*JobsProgressResponse)(nil), // 138: abi.v1.JobsProgressResponse
|
(*JobsProgressResponse)(nil), // 138: abi.v1.JobsProgressResponse
|
||||||
(*BridgeInvokeRequest)(nil), // 139: abi.v1.BridgeInvokeRequest
|
(*BridgeInvokeRequest)(nil), // 139: abi.v1.BridgeInvokeRequest
|
||||||
(*BridgeInvokeResponse)(nil), // 140: abi.v1.BridgeInvokeResponse
|
(*BridgeInvokeResponse)(nil), // 140: abi.v1.BridgeInvokeResponse
|
||||||
nil, // 141: abi.v1.AuthorProfile.SocialLinksEntry
|
(*HttpRequestRequest)(nil), // 141: abi.v1.HttpRequestRequest
|
||||||
nil, // 142: abi.v1.RagResult.MetadataEntry
|
(*HttpRequestResponse)(nil), // 142: abi.v1.HttpRequestResponse
|
||||||
(*AbiError)(nil), // 143: abi.v1.AbiError
|
nil, // 143: abi.v1.AuthorProfile.SocialLinksEntry
|
||||||
(*timestamppb.Timestamp)(nil), // 144: google.protobuf.Timestamp
|
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{
|
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
|
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
|
7, // 3: abi.v1.ContentGetPageResponse.page:type_name -> abi.v1.PageInfo
|
||||||
12, // 4: abi.v1.ContentGetPostResponse.post:type_name -> abi.v1.PostInfo
|
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
|
12, // 6: abi.v1.ContentListPostsResponse.posts:type_name -> abi.v1.PostInfo
|
||||||
33, // 7: abi.v1.GatingEvaluateAccessRequest.rule:type_name -> abi.v1.AccessRule
|
33, // 7: abi.v1.GatingEvaluateAccessRequest.rule:type_name -> abi.v1.AccessRule
|
||||||
34, // 8: abi.v1.GatingEvaluateAccessResponse.result:type_name -> abi.v1.AccessResult
|
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, // 16: abi.v1.SubscriptionsGetTierBySlugResponse.tier:type_name -> abi.v1.Tier
|
||||||
60, // 17: abi.v1.SubscriptionsListTiersResponse.tiers: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
|
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
|
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, // 22: abi.v1.ContentSetPageBlocksRequest.blocks:type_name -> abi.v1.PageBlock
|
||||||
97, // 23: abi.v1.PageSeed.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
|
112, // 24: abi.v1.ProvisionerEnsurePageRequest.page:type_name -> abi.v1.PageSeed
|
||||||
25, // [25:25] is the sub-list for method output_type
|
145, // 25: abi.v1.HttpRequestRequest.headers:type_name -> abi.v1.HttpRequestRequest.HeadersEntry
|
||||||
25, // [25:25] is the sub-list for method input_type
|
146, // 26: abi.v1.HttpRequestResponse.headers:type_name -> abi.v1.HttpRequestResponse.HeadersEntry
|
||||||
25, // [25:25] is the sub-list for extension type_name
|
149, // 27: abi.v1.HttpRequestRequest.HeadersEntry.value:type_name -> abi.v1.HeaderValues
|
||||||
25, // [25:25] is the sub-list for extension extendee
|
149, // 28: abi.v1.HttpRequestResponse.HeadersEntry.value:type_name -> abi.v1.HeaderValues
|
||||||
0, // [0:25] is the sub-list for field type_name
|
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() }
|
func init() { file_v1_capability_proto_init() }
|
||||||
@ -8167,6 +8352,7 @@ func file_v1_capability_proto_init() {
|
|||||||
if File_v1_capability_proto != nil {
|
if File_v1_capability_proto != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
file_v1_http_proto_init()
|
||||||
file_v1_invoke_proto_init()
|
file_v1_invoke_proto_init()
|
||||||
file_v1_capability_proto_msgTypes[44].OneofWrappers = []any{}
|
file_v1_capability_proto_msgTypes[44].OneofWrappers = []any{}
|
||||||
file_v1_capability_proto_msgTypes[97].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(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_capability_proto_rawDesc), len(file_v1_capability_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_capability_proto_rawDesc), len(file_v1_capability_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 143,
|
NumMessages: 147,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -168,6 +168,12 @@ const (
|
|||||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||||
// bnwasm.ErrTxExpired.
|
// bnwasm.ErrTxExpired.
|
||||||
AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED AbiErrorCode = 6
|
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.
|
// Enum value maps for AbiErrorCode.
|
||||||
@ -180,6 +186,7 @@ var (
|
|||||||
4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED",
|
4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED",
|
||||||
5: "ABI_ERROR_CODE_PERMISSION_DENIED",
|
5: "ABI_ERROR_CODE_PERMISSION_DENIED",
|
||||||
6: "ABI_ERROR_CODE_TX_EXPIRED",
|
6: "ABI_ERROR_CODE_TX_EXPIRED",
|
||||||
|
7: "ABI_ERROR_CODE_EGRESS_DENIED",
|
||||||
}
|
}
|
||||||
AbiErrorCode_value = map[string]int32{
|
AbiErrorCode_value = map[string]int32{
|
||||||
"ABI_ERROR_CODE_UNSPECIFIED": 0,
|
"ABI_ERROR_CODE_UNSPECIFIED": 0,
|
||||||
@ -189,6 +196,7 @@ var (
|
|||||||
"ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4,
|
"ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4,
|
||||||
"ABI_ERROR_CODE_PERMISSION_DENIED": 5,
|
"ABI_ERROR_CODE_PERMISSION_DENIED": 5,
|
||||||
"ABI_ERROR_CODE_TX_EXPIRED": 6,
|
"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" +
|
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
|
||||||
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
|
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
|
||||||
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\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" +
|
"\fAbiErrorCode\x12\x1e\n" +
|
||||||
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
||||||
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\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" +
|
"\x1cABI_ERROR_CODE_UNIMPLEMENTED\x10\x03\x12$\n" +
|
||||||
" ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" +
|
" ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" +
|
||||||
" ABI_ERROR_CODE_PERMISSION_DENIED\x10\x05\x12\x1d\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 (
|
var (
|
||||||
file_v1_invoke_proto_rawDescOnce sync.Once
|
file_v1_invoke_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@ -122,7 +122,19 @@ type PluginManifest struct {
|
|||||||
// both reject the combination. Not produced by DESCRIBE (there is no guest
|
// both reject the combination. Not produced by DESCRIBE (there is no guest
|
||||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||||
// plugin.mod + the artifact's declarative files.
|
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -388,6 +400,20 @@ func (x *PluginManifest) GetCodeless() bool {
|
|||||||
return false
|
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.
|
// Dependency mirrors plugin.Dependency.
|
||||||
type Dependency struct {
|
type Dependency struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -1300,7 +1326,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_v1_manifest_proto_rawDesc = "" +
|
const file_v1_manifest_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" +
|
"\x11v1/manifest.proto\x12\x06abi.v1\"\xdb\r\n" +
|
||||||
"\x0ePluginManifest\x12\x1f\n" +
|
"\x0ePluginManifest\x12\x1f\n" +
|
||||||
"\vabi_version\x18\x01 \x01(\rR\n" +
|
"\vabi_version\x18\x01 \x01(\rR\n" +
|
||||||
"abiVersion\x12\x12\n" +
|
"abiVersion\x12\x12\n" +
|
||||||
@ -1338,7 +1364,9 @@ const file_v1_manifest_proto_rawDesc = "" +
|
|||||||
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
||||||
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
||||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\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" +
|
"\x14RbacMethodRolesEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +
|
||||||
|
|||||||
183
egress/egress.go
Normal file
183
egress/egress.go
Normal file
@ -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
|
||||||
|
}
|
||||||
67
egress/egress_test.go
Normal file
67
egress/egress_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
go.mod
6
go.mod
@ -4,19 +4,19 @@ go 1.26.4
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
|
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/tetratelabs/wazero v1.12.0
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
golang.org/x/mod v0.37.0
|
golang.org/x/mod v0.37.0
|
||||||
|
golang.org/x/net v0.56.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@ -1,7 +1,5 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
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 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
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=
|
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 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||||
@ -52,6 +53,13 @@ type CoreServices struct {
|
|||||||
// Plugin interop
|
// Plugin interop
|
||||||
Bridge PluginBridge
|
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
|
// Core RPC services — pre-built bindings for CMS-provided services
|
||||||
CoreServiceBindings CoreServiceBindings
|
CoreServiceBindings CoreServiceBindings
|
||||||
ReviewSubmitter ReviewSubmitter
|
ReviewSubmitter ReviewSubmitter
|
||||||
|
|||||||
@ -53,6 +53,18 @@ type ModPlugin struct {
|
|||||||
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
||||||
// straight from plugin.mod as a fallback. OFF by default.
|
// straight from plugin.mod as a fallback. OFF by default.
|
||||||
DataDir bool `toml:"data_dir,omitempty"`
|
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 {
|
type ModCompat struct {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -13,6 +15,7 @@ import (
|
|||||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
"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/gating"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"github.com/google/uuid"
|
"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
|
// --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a
|
||||||
// CoreServices stub, so the case drives the transport directly) ---
|
// 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) {
|
func TestNilTransportFailsCleanly(t *testing.T) {
|
||||||
cs := NewCoreServices(nil)
|
cs := NewCoreServices(nil)
|
||||||
_, err := cs.Content.GetPage(context.Background(), "about")
|
_, err := cs.Content.GetPage(context.Background(), "about")
|
||||||
|
|||||||
@ -47,5 +47,6 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
|||||||
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
||||||
RAGService: NewRAGStub(call),
|
RAGService: NewRAGStub(call),
|
||||||
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
|
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
|
||||||
|
OutboundHTTP: &httpStub{base{family: "http", call: call}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
plugin/wasmguest/caps/net.go
Normal file
87
plugin/wasmguest/caps/net.go
Normal file
@ -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
|
||||||
|
}
|
||||||
4
plugin/wasmguest/caps/testdata/golden/http_request_req.pb
vendored
Normal file
4
plugin/wasmguest/caps/testdata/golden/http_request_req.pb
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
GEThttps://api.cal.com/v1/slots
|
||||||
|
Authorization
|
||||||
|
Bearer test
|
||||||
3
plugin/wasmguest/caps/testdata/golden/http_request_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/http_request_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
È"
|
||||||
|
Content-Type
|
||||||
|
application/json{"ok":true}"https://api.cal.com/v1/slots
|
||||||
Loading…
x
Reference in New Issue
Block a user