lightning-terminal/scripting/builtins_http.go

160 lines
3.7 KiB
Go
Raw Permalink Normal View History

package scripting
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gobwas/glob"
"go.starlark.net/starlark"
)
const (
// MaxHTTPResponseSize is the maximum response body size we'll read.
MaxHTTPResponseSize = 10 * 1024 * 1024 // 10MB
// HTTPRequestTimeout is the timeout for HTTP requests.
HTTPRequestTimeout = 30 * time.Second
)
// registerHTTPBuiltins adds HTTP-related builtin functions.
func (e *Engine) registerHTTPBuiltins(predeclared starlark.StringDict) {
predeclared["http_get"] = starlark.NewBuiltin("http_get", e.builtinHTTPGet)
}
// builtinHTTPGet implements http_get(url, headers={}).
func (e *Engine) builtinHTTPGet(thread *starlark.Thread, fn *starlark.Builtin,
args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
var url string
var headersDict *starlark.Dict
if err := starlark.UnpackArgs("http_get", args, kwargs,
"url", &url,
"headers?", &headersDict,
); err != nil {
return nil, err
}
// Validate URL against allowlist.
if !e.isURLAllowed(url) {
return nil, fmt.Errorf("URL not in allowlist: %s", url)
}
// Check sandbox context before making request.
if err := e.sandbox.CheckContext(); err != nil {
return nil, err
}
// Create HTTP client with timeout.
client := &http.Client{
Timeout: HTTPRequestTimeout,
}
// Create request.
req, err := http.NewRequestWithContext(e.sandbox.Context(), "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add custom headers if provided.
if headersDict != nil {
for _, item := range headersDict.Items() {
key, ok := item[0].(starlark.String)
if !ok {
return nil, fmt.Errorf("header key must be string")
}
value, ok := item[1].(starlark.String)
if !ok {
return nil, fmt.Errorf("header value must be string")
}
req.Header.Set(string(key), string(value))
}
}
// Set a reasonable User-Agent.
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "LiT-Script/1.0")
}
// Make the request.
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Read the response body with size limit.
bodyReader := io.LimitReader(resp.Body, MaxHTTPResponseSize)
body, err := io.ReadAll(bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Track memory usage for the response body.
if err := e.sandbox.CheckMemory(int64(len(body))); err != nil {
return nil, err
}
defer e.sandbox.ReleaseMemory(int64(len(body)))
// Build response dictionary.
result := NewStarlarkDict()
if err := result.Set("status", resp.StatusCode); err != nil {
return nil, err
}
if err := result.Set("body", string(body)); err != nil {
return nil, err
}
// Convert response headers to dict.
headersResult := starlark.NewDict(len(resp.Header))
for key, values := range resp.Header {
// Join multiple values with comma.
value := strings.Join(values, ", ")
if err := headersResult.SetKey(
starlark.String(key),
starlark.String(value),
); err != nil {
return nil, err
}
}
if err := result.SetStarlark("headers", headersResult); err != nil {
return nil, err
}
return result.Dict(), nil
}
// isURLAllowed checks if a URL matches the script's allowlist.
func (e *Engine) isURLAllowed(url string) bool {
// If no allowlist is configured, deny all URLs.
if len(e.allowedURLs) == 0 {
return false
}
for _, pattern := range e.allowedURLs {
// Check for exact match first.
if pattern == url {
return true
}
// Try glob matching.
g, err := glob.Compile(pattern)
if err != nil {
// Invalid pattern, skip.
log.Warnf("Invalid URL pattern '%s': %v", pattern, err)
continue
}
if g.Match(url) {
return true
}
}
return false
}