Skip to content

Commit 45bd6d9

Browse files
committed
Add support for remote manifest
Supports http(s) urls for manifest. It implements the following safeguards - HTTP timeout (30s) - File size validation (10MB) - YAML validation - YAML document limit (10) - Prevent redirect loops Signed-off-by: Pradipta Banerjee <[email protected]>
1 parent a5624e9 commit 45bd6d9

File tree

3 files changed

+262
-8
lines changed

3 files changed

+262
-8
lines changed

cmd/apply.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,20 @@ var applyCmd = &cobra.Command{
3030
Long: `Transform a regular Kubernetes manifest to a CoCo-enabled manifest and apply it.
3131
3232
This command will:
33-
1. Load the specified manifest
33+
1. Load the specified manifest (local file or URL)
3434
2. Add/update RuntimeClass
3535
3. Add initdata annotation
3636
4. Add first initContainer for attestation (if requested)
3737
5. Save a backup of the transformed manifest (*-coco.yaml)
3838
6. Apply the transformed manifest using kubectl
3939
40+
Supports both local files and remote URLs (http/https).
41+
4042
Example:
4143
kubectl coco apply -f app.yaml
4244
kubectl coco apply -f app.yaml --runtime-class kata-remote
43-
kubectl coco apply -f app.yaml --init-container`,
45+
kubectl coco apply -f app.yaml --init-container
46+
kubectl coco apply -f https://raw.githubusercontent.com/user/repo/main/app.yaml`,
4447
RunE: runApply,
4548
}
4649

@@ -64,7 +67,7 @@ var (
6467
func init() {
6568
rootCmd.AddCommand(applyCmd)
6669

67-
applyCmd.Flags().StringVarP(&manifestFile, "filename", "f", "", "Path to Kubernetes manifest file (required)")
70+
applyCmd.Flags().StringVarP(&manifestFile, "filename", "f", "", "Path to Kubernetes manifest file or URL (required)")
6871
applyCmd.Flags().StringVar(&runtimeClass, "runtime-class", "", "RuntimeClass to use (default from config)")
6972
applyCmd.Flags().BoolVar(&addInitContainer, "init-container", false, "Add default attestation initContainer")
7073
applyCmd.Flags().StringVar(&initContainerImg, "init-container-img", "", "Custom init container image (requires --init-container)")
@@ -110,9 +113,26 @@ func runApply(_ *cobra.Command, _ []string) error {
110113
rc = cfg.RuntimeClass
111114
}
112115

116+
// Handle remote files
117+
actualManifestFile := manifestFile
118+
var tempFile string
119+
if isRemoteFile(manifestFile) {
120+
fmt.Printf("Downloading remote manifest: %s\n", manifestFile)
121+
var err error
122+
tempFile, err = downloadRemoteFile(manifestFile)
123+
if err != nil {
124+
return fmt.Errorf("failed to download remote manifest: %w", err)
125+
}
126+
defer func() {
127+
_ = os.Remove(tempFile)
128+
}()
129+
actualManifestFile = tempFile
130+
fmt.Printf("Downloaded to: %s\n", tempFile)
131+
}
132+
113133
// Load manifest (supports multi-document YAML)
114-
fmt.Printf("Loading manifest: %s\n", manifestFile)
115-
manifestSet, err := manifest.LoadMultiDocument(manifestFile)
134+
fmt.Printf("Loading manifest: %s\n", actualManifestFile)
135+
manifestSet, err := manifest.LoadMultiDocument(actualManifestFile)
116136
if err != nil {
117137
return fmt.Errorf("failed to load manifest: %w", err)
118138
}

cmd/common.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"time"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
const (
18+
// maxYAMLDocuments is the maximum number of YAML documents allowed in a manifest file
19+
// Typical K8s manifests have 1-5 documents. This limit protects against accidentally
20+
// parsing non-YAML content (like HTML) which could be interpreted as many documents.
21+
maxYAMLDocuments = 10
22+
23+
// maxManifestSize is the maximum size allowed for a remote manifest file
24+
// Typical K8s manifests are a few KB. This limit (10MB) protects against excessive
25+
// disk usage from malicious or misconfigured remote URLs.
26+
maxManifestSize = 10 * 1024 * 1024 // 10MB
27+
28+
// maxRedirects is the maximum number of HTTP redirects to follow
29+
// This prevents redirect loops and excessive redirect chains.
30+
maxRedirects = 5
31+
)
32+
33+
// isRemoteFile checks if the given path is a URL
34+
func isRemoteFile(path string) bool {
35+
u, err := url.Parse(path)
36+
if err != nil {
37+
return false
38+
}
39+
return u.Scheme == "http" || u.Scheme == "https"
40+
}
41+
42+
// isPrivateIP checks if an IP address is in a private/internal range
43+
func isPrivateIP(ip net.IP) bool {
44+
// Check for loopback addresses (127.0.0.0/8, ::1)
45+
if ip.IsLoopback() {
46+
return true
47+
}
48+
49+
// Check for link-local addresses (169.254.0.0/16, fe80::/10)
50+
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
51+
return true
52+
}
53+
54+
// Check for private IPv4 ranges
55+
// 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
56+
privateIPv4Ranges := []string{
57+
"10.0.0.0/8",
58+
"172.16.0.0/12",
59+
"192.168.0.0/16",
60+
}
61+
62+
for _, cidr := range privateIPv4Ranges {
63+
_, network, _ := net.ParseCIDR(cidr)
64+
if network.Contains(ip) {
65+
return true
66+
}
67+
}
68+
69+
// Check for private IPv6 ranges (fc00::/7 - Unique Local Addresses)
70+
if len(ip) == net.IPv6len && (ip[0] == 0xfc || ip[0] == 0xfd) {
71+
return true
72+
}
73+
74+
return false
75+
}
76+
77+
// downloadRemoteFile downloads a remote file and returns the path to a temporary file
78+
func downloadRemoteFile(remoteURL string) (string, error) {
79+
// Track redirect count to prevent loops and excessive redirects
80+
redirectCount := 0
81+
82+
// Create HTTP client with timeout and redirect validation
83+
client := &http.Client{
84+
Timeout: 30 * time.Second,
85+
CheckRedirect: func(req *http.Request, _ []*http.Request) error {
86+
// Limit number of redirects
87+
redirectCount++
88+
if redirectCount > maxRedirects {
89+
return fmt.Errorf("too many redirects (max: %d)", maxRedirects)
90+
}
91+
92+
// Validate redirect URL scheme
93+
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
94+
return fmt.Errorf("redirect to unsupported scheme: %s", req.URL.Scheme)
95+
}
96+
97+
// Resolve hostname to IP addresses for SSRF protection
98+
hostname := req.URL.Hostname()
99+
ips, err := net.LookupIP(hostname)
100+
if err != nil {
101+
// DNS lookup failed - this could be temporary or the hostname might not exist
102+
// We allow the redirect to proceed and let the actual HTTP request handle the error
103+
// This avoids blocking legitimate redirects due to transient DNS issues
104+
// nolint:nilerr // Intentionally returning nil when DNS lookup fails
105+
return nil
106+
}
107+
108+
// Check if any resolved IP is private/internal (SSRF protection)
109+
for _, ip := range ips {
110+
if isPrivateIP(ip) {
111+
return fmt.Errorf("redirect to private/internal IP address blocked: %s resolves to %s", hostname, ip.String())
112+
}
113+
}
114+
115+
return nil
116+
},
117+
}
118+
119+
// Download the file
120+
// #nosec G107 - URL is user-provided manifest location
121+
resp, err := client.Get(remoteURL)
122+
if err != nil {
123+
return "", fmt.Errorf("failed to download remote file: %w", err)
124+
}
125+
defer func() {
126+
_ = resp.Body.Close()
127+
}()
128+
129+
if resp.StatusCode != http.StatusOK {
130+
return "", fmt.Errorf("failed to download remote file: HTTP %d", resp.StatusCode)
131+
}
132+
133+
// Check Content-Length header if available to fail fast
134+
if resp.ContentLength > 0 && resp.ContentLength > maxManifestSize {
135+
return "", fmt.Errorf("remote file too large: %d bytes (max: %d bytes)", resp.ContentLength, maxManifestSize)
136+
}
137+
138+
// Check Content-Type header (if present)
139+
// Note: We don't enforce Content-Type validation because some servers
140+
// (like GitHub raw.githubusercontent.com) serve YAML as text/plain
141+
contentType := resp.Header.Get("Content-Type")
142+
143+
// Use LimitReader as safety net to enforce hard limit
144+
// (in case Content-Length is not set, incorrect, or malicious)
145+
// Read one extra byte to detect if limit was exceeded
146+
limitedReader := io.LimitReader(resp.Body, maxManifestSize+1)
147+
content, err := io.ReadAll(limitedReader)
148+
if err != nil {
149+
return "", fmt.Errorf("failed to read response body: %w", err)
150+
}
151+
152+
// Check if we exceeded the size limit
153+
if len(content) > maxManifestSize {
154+
return "", fmt.Errorf("remote file too large: exceeds maximum size of %d bytes", maxManifestSize)
155+
}
156+
157+
// Validate that content is valid YAML
158+
if err := validateYAML(content); err != nil {
159+
return "", fmt.Errorf("downloaded content is not valid YAML: %w\nURL: %s\nContent-Type: %s", err, remoteURL, contentType)
160+
}
161+
162+
// Create temporary file
163+
tmpFile, err := os.CreateTemp("", "kubectl-coco-*.yaml")
164+
if err != nil {
165+
return "", fmt.Errorf("failed to create temporary file: %w", err)
166+
}
167+
defer func() {
168+
_ = tmpFile.Close()
169+
}()
170+
171+
// Write validated content to temporary file
172+
_, err = tmpFile.Write(content)
173+
if err != nil {
174+
_ = os.Remove(tmpFile.Name())
175+
return "", fmt.Errorf("failed to write temporary file: %w", err)
176+
}
177+
178+
return tmpFile.Name(), nil
179+
}
180+
181+
// validateYAML checks if the content is valid YAML
182+
func validateYAML(content []byte) error {
183+
// Try to parse as YAML
184+
decoder := yaml.NewDecoder(bytes.NewReader(content))
185+
186+
// Attempt to decode all documents in the YAML
187+
var docCount int
188+
for {
189+
var doc interface{}
190+
err := decoder.Decode(&doc)
191+
if errors.Is(err, io.EOF) {
192+
break
193+
}
194+
if err != nil {
195+
return fmt.Errorf("YAML parsing error: %w", err)
196+
}
197+
docCount++
198+
199+
// Sanity check: protect against accidentally parsing non-YAML content
200+
if docCount > maxYAMLDocuments {
201+
return fmt.Errorf("file contains too many YAML documents (>%d), might not be a valid manifest", maxYAMLDocuments)
202+
}
203+
}
204+
205+
// Check if we got at least one document
206+
if docCount == 0 {
207+
return fmt.Errorf("file is empty or contains no valid YAML documents")
208+
}
209+
210+
return nil
211+
}

cmd/explain.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,15 @@ a regular Kubernetes manifest to a CoCo-enabled manifest.
2121
This command is purely educational and does not require a cluster connection.
2222
It helps you understand what changes are made to enable Confidential Containers.
2323
24+
Supports both local files and remote URLs (http/https).
25+
2426
Examples:
2527
# Explain transformations on your manifest
2628
kubectl coco explain -f app.yaml
2729
30+
# Explain from remote URL
31+
kubectl coco explain -f https://raw.githubusercontent.com/user/repo/main/app.yaml
32+
2833
# Use a built-in example
2934
kubectl coco explain --example simple-pod
3035
kubectl coco explain --example deployment-secrets
@@ -56,7 +61,7 @@ var (
5661
func init() {
5762
rootCmd.AddCommand(explainCmd)
5863

59-
explainCmd.Flags().StringVarP(&explainManifestFile, "filename", "f", "", "Path to Kubernetes manifest file")
64+
explainCmd.Flags().StringVarP(&explainManifestFile, "filename", "f", "", "Path to Kubernetes manifest file or URL")
6065
explainCmd.Flags().StringVar(&explainExample, "example", "", "Use built-in example (simple-pod, deployment-secrets, sidecar-service)")
6166
explainCmd.Flags().StringVar(&explainFormat, "format", "text", "Output format: text, diff, markdown")
6267
explainCmd.Flags().BoolVar(&explainListExamples, "list-examples", false, "List available built-in examples")
@@ -77,6 +82,7 @@ func runExplain(_ *cobra.Command, _ []string) error {
7782
var manifestPath string
7883
var manifestContent string
7984
var isExample bool
85+
var tempFile string
8086

8187
if explainExample != "" {
8288
// Use built-in example
@@ -108,8 +114,25 @@ func runExplain(_ *cobra.Command, _ []string) error {
108114
manifestContent = ex.Manifest
109115
isExample = true
110116
} else if explainManifestFile != "" {
111-
// Use user-provided manifest
112-
manifestPath = explainManifestFile
117+
// Check if it's a remote URL
118+
if isRemoteFile(explainManifestFile) {
119+
fmt.Printf("📥 Downloading remote manifest: %s\n", explainManifestFile)
120+
var err error
121+
tempFile, err = downloadRemoteFile(explainManifestFile)
122+
if err != nil {
123+
return fmt.Errorf("failed to download remote manifest: %w", err)
124+
}
125+
defer func() {
126+
_ = os.Remove(tempFile)
127+
}()
128+
manifestPath = tempFile
129+
fmt.Printf(" Downloaded to: %s\n\n", tempFile)
130+
} else {
131+
// Use local file
132+
manifestPath = explainManifestFile
133+
}
134+
135+
// Read manifest content
113136
// #nosec G304 - User-provided manifest file path is expected
114137
data, err := os.ReadFile(manifestPath)
115138
if err != nil {

0 commit comments

Comments
 (0)