cef: remove /api/transcode scheme route
- commit
- a909bde088706fd7c654a90f1f9219ecd8eea45c
- author
- brice · · 2026-04-28 03:44 UTC
- parent
- b15abc4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +0 -110
M internal/infrastructure/cef/scheme_handler.go +0 -91
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "html" | |
| 11 | 11 | "io" | |
| 12 | 12 | "io/fs" | |
| 13 | - | "net" | |
| 14 | 13 | "net/http" | |
| 15 | 14 | "net/url" | |
| 16 | 15 | "os" | |
| @@ -18,7 +17,6 @@ import ( | |||
| 18 | 17 | "strconv" | |
| 19 | 18 | "strings" | |
| 20 | 19 | "sync" | |
| 21 | - | "time" | |
| 22 | 20 | "unsafe" | |
| 23 | 21 | ||
| 24 | 22 | "github.com/andybalholm/brotli" | |
| @@ -26,7 +24,6 @@ import ( | |||
| 26 | 24 | ||
| 27 | 25 | "github.com/bnema/dumber/internal/application/port" | |
| 28 | 26 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 29 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |
| 30 | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 31 | 28 | "github.com/bnema/dumber/internal/logging" | |
| 32 | 29 | "github.com/rs/zerolog" | |
| @@ -227,8 +224,6 @@ func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Req | |||
| 227 | 224 | return denied, true | |
| 228 | 225 | } | |
| 229 | 226 | return h.handleConfigAPI(h.defaultConfigPayload), true | |
| 230 | - | case "/api/transcode": | |
| 231 | - | return h.handleTranscodeAPI(request), true | |
| 232 | 227 | case "/api/favicon": | |
| 233 | 228 | return h.handleFaviconAPI(request), true | |
| 234 | 229 | default: | |
| @@ -370,92 +365,6 @@ func isTrustedSystemviewURL(raw string) bool { | |||
| 370 | 365 | return isInternalPageHost(host) | |
| 371 | 366 | } | |
| 372 | 367 | ||
| 373 | - | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 374 | - | if h.transcoder == nil { | |
| 375 | - | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | reqURL := request.GetURL() | |
| 379 | - | parsed, err := url.Parse(reqURL) | |
| 380 | - | if err != nil { | |
| 381 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |
| 385 | - | if sourceURL == "" { | |
| 386 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | sourceParsed, err := url.Parse(sourceURL) | |
| 390 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | |
| 391 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |
| 392 | - | } | |
| 393 | - | headers := make(map[string]string) | |
| 394 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |
| 395 | - | headers["User-Agent"] = userAgent | |
| 396 | - | } | |
| 397 | - | if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" { | |
| 398 | - | headers["Referer"] = referer | |
| 399 | - | } else if referer := request.GetReferrerURL(); referer != "" { | |
| 400 | - | headers["Referer"] = referer | |
| 401 | - | } | |
| 402 | - | if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" { | |
| 403 | - | headers["Origin"] = origin | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | |
| 407 | - | h.logger.Info(). | |
| 408 | - | Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)). | |
| 409 | - | Int("forwarded_header_count", len(headers)). | |
| 410 | - | Msg("scheme handler returning transcoding stream") | |
| 411 | - | ||
| 412 | - | return cefNewResourceHandler(&transcodingResourceHandler{ | |
| 413 | - | transcoder: h.transcoder, | |
| 414 | - | sourceURL: sourceURL, | |
| 415 | - | headers: headers, | |
| 416 | - | ctx: ctx, | |
| 417 | - | cancel: cancel, | |
| 418 | - | validateSource: func(ctx context.Context) error { | |
| 419 | - | return validateTranscodeSourceURL(ctx, sourceParsed) | |
| 420 | - | }, | |
| 421 | - | logf: func() zerolog.Logger { | |
| 422 | - | return h.logger.With().Str("component", "scheme-transcoding").Logger() | |
| 423 | - | }, | |
| 424 | - | }) | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |
| 428 | - | if source == nil || source.Host == "" { | |
| 429 | - | return fmt.Errorf("invalid src") | |
| 430 | - | } | |
| 431 | - | host := source.Hostname() | |
| 432 | - | if host == "" { | |
| 433 | - | return fmt.Errorf("invalid src host") | |
| 434 | - | } | |
| 435 | - | if ip := net.ParseIP(host); ip != nil { | |
| 436 | - | if isBlockedTranscodeIP(ip) { | |
| 437 | - | return fmt.Errorf("private src host not allowed") | |
| 438 | - | } | |
| 439 | - | return nil | |
| 440 | - | } | |
| 441 | - | ||
| 442 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 443 | - | defer cancel() | |
| 444 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |
| 445 | - | if err != nil || len(addrs) == 0 { | |
| 446 | - | return fmt.Errorf("src host could not be resolved") | |
| 447 | - | } | |
| 448 | - | for _, addr := range addrs { | |
| 449 | - | if isBlockedTranscodeIP(addr.IP) { | |
| 450 | - | return fmt.Errorf("private src host not allowed") | |
| 451 | - | } | |
| 452 | - | } | |
| 453 | - | return nil | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |
| 457 | - | return netguard.IsBlockedTranscodeIP(ip) | |
| 458 | - | } | |
| 459 | 368 | ||
| 460 | 369 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |
| 461 | 370 | if build == nil { | |
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | "html" | 10 | "html" | ||
| 11 | "io" | 11 | "io" | ||
| 12 | "io/fs" | 12 | "io/fs" | ||
| 13 | - | "net" | |||
| 14 | "net/http" | 13 | "net/http" | ||
| 15 | "net/url" | 14 | "net/url" | ||
| 16 | "os" | 15 | "os" | ||
| @@ -18,7 +17,6 @@ import ( | |||||
| 18 | "strconv" | 17 | "strconv" | ||
| 19 | "strings" | 18 | "strings" | ||
| 20 | "sync" | 19 | "sync" | ||
| 21 | - | "time" | |||
| 22 | "unsafe" | 20 | "unsafe" | ||
| 23 | 21 | ||||
| 24 | "github.com/andybalholm/brotli" | 22 | "github.com/andybalholm/brotli" | ||
| @@ -26,7 +24,6 @@ import ( | |||||
| 26 | 24 | ||||
| 27 | "github.com/bnema/dumber/internal/application/port" | 25 | "github.com/bnema/dumber/internal/application/port" | ||
| 28 | domainurl "github.com/bnema/dumber/internal/domain/url" | 26 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 29 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 30 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 31 | "github.com/bnema/dumber/internal/logging" | 28 | "github.com/bnema/dumber/internal/logging" | ||
| 32 | "github.com/rs/zerolog" | 29 | "github.com/rs/zerolog" | ||
| @@ -227,8 +224,6 @@ func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Req | |||||
| 227 | return denied, true | 224 | return denied, true | ||
| 228 | } | 225 | } | ||
| 229 | return h.handleConfigAPI(h.defaultConfigPayload), true | 226 | return h.handleConfigAPI(h.defaultConfigPayload), true | ||
| 230 | - | case "/api/transcode": | |||
| 231 | - | return h.handleTranscodeAPI(request), true | |||
| 232 | case "/api/favicon": | 227 | case "/api/favicon": | ||
| 233 | return h.handleFaviconAPI(request), true | 228 | return h.handleFaviconAPI(request), true | ||
| 234 | default: | 229 | default: | ||
| @@ -370,92 +365,6 @@ func isTrustedSystemviewURL(raw string) bool { | |||||
| 370 | return isInternalPageHost(host) | 365 | return isInternalPageHost(host) | ||
| 371 | } | 366 | } | ||
| 372 | 367 | ||||
| 373 | - | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |||
| 374 | - | if h.transcoder == nil { | |||
| 375 | - | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | reqURL := request.GetURL() | |||
| 379 | - | parsed, err := url.Parse(reqURL) | |||
| 380 | - | if err != nil { | |||
| 381 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |||
| 385 | - | if sourceURL == "" { | |||
| 386 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | sourceParsed, err := url.Parse(sourceURL) | |||
| 390 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | |||
| 391 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |||
| 392 | - | } | |||
| 393 | - | headers := make(map[string]string) | |||
| 394 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 395 | - | headers["User-Agent"] = userAgent | |||
| 396 | - | } | |||
| 397 | - | if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" { | |||
| 398 | - | headers["Referer"] = referer | |||
| 399 | - | } else if referer := request.GetReferrerURL(); referer != "" { | |||
| 400 | - | headers["Referer"] = referer | |||
| 401 | - | } | |||
| 402 | - | if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" { | |||
| 403 | - | headers["Origin"] = origin | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | |||
| 407 | - | h.logger.Info(). | |||
| 408 | - | Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)). | |||
| 409 | - | Int("forwarded_header_count", len(headers)). | |||
| 410 | - | Msg("scheme handler returning transcoding stream") | |||
| 411 | - | ||||
| 412 | - | return cefNewResourceHandler(&transcodingResourceHandler{ | |||
| 413 | - | transcoder: h.transcoder, | |||
| 414 | - | sourceURL: sourceURL, | |||
| 415 | - | headers: headers, | |||
| 416 | - | ctx: ctx, | |||
| 417 | - | cancel: cancel, | |||
| 418 | - | validateSource: func(ctx context.Context) error { | |||
| 419 | - | return validateTranscodeSourceURL(ctx, sourceParsed) | |||
| 420 | - | }, | |||
| 421 | - | logf: func() zerolog.Logger { | |||
| 422 | - | return h.logger.With().Str("component", "scheme-transcoding").Logger() | |||
| 423 | - | }, | |||
| 424 | - | }) | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |||
| 428 | - | if source == nil || source.Host == "" { | |||
| 429 | - | return fmt.Errorf("invalid src") | |||
| 430 | - | } | |||
| 431 | - | host := source.Hostname() | |||
| 432 | - | if host == "" { | |||
| 433 | - | return fmt.Errorf("invalid src host") | |||
| 434 | - | } | |||
| 435 | - | if ip := net.ParseIP(host); ip != nil { | |||
| 436 | - | if isBlockedTranscodeIP(ip) { | |||
| 437 | - | return fmt.Errorf("private src host not allowed") | |||
| 438 | - | } | |||
| 439 | - | return nil | |||
| 440 | - | } | |||
| 441 | - | ||||
| 442 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 443 | - | defer cancel() | |||
| 444 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 445 | - | if err != nil || len(addrs) == 0 { | |||
| 446 | - | return fmt.Errorf("src host could not be resolved") | |||
| 447 | - | } | |||
| 448 | - | for _, addr := range addrs { | |||
| 449 | - | if isBlockedTranscodeIP(addr.IP) { | |||
| 450 | - | return fmt.Errorf("private src host not allowed") | |||
| 451 | - | } | |||
| 452 | - | } | |||
| 453 | - | return nil | |||
| 454 | - | } | |||
| 455 | - | ||||
| 456 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 457 | - | return netguard.IsBlockedTranscodeIP(ip) | |||
| 458 | - | } | |||
| 459 | 368 | ||||
| 460 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | 369 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | ||
| 461 | if build == nil { | 370 | if build == nil { | ||
M internal/infrastructure/cef/scheme_handler_test.go +0 -19
| @@ -4,9 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | - | "net" | |
| 8 | 7 | "net/http" | |
| 9 | - | "net/url" | |
| 10 | 8 | "testing" | |
| 11 | 9 | ||
| 12 | 10 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -315,23 +313,6 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||
| 315 | 313 | require.NotNil(t, h.handleFaviconAPI(request)) | |
| 316 | 314 | } | |
| 317 | 315 | ||
| 318 | - | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |
| 319 | - | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |
| 320 | - | require.NoError(t, err) | |
| 321 | - | ||
| 322 | - | err = validateTranscodeSourceURL(context.Background(), privateURL) | |
| 323 | - | require.Error(t, err) | |
| 324 | - | require.Contains(t, err.Error(), "private src host") | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |
| 328 | - | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |
| 329 | - | t.Run(rawIP, func(t *testing.T) { | |
| 330 | - | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |
| 331 | - | }) | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | ||
| 335 | 316 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 336 | 317 | request := cefmocks.NewMockRequest(t) | |
| 337 | 318 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| @@ -4,9 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "net" | |||
| 8 | "net/http" | 7 | "net/http" | ||
| 9 | - | "net/url" | |||
| 10 | "testing" | 8 | "testing" | ||
| 11 | 9 | ||||
| 12 | purecef "github.com/bnema/purego-cef/cef" | 10 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -315,23 +313,6 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||||
| 315 | require.NotNil(t, h.handleFaviconAPI(request)) | 313 | require.NotNil(t, h.handleFaviconAPI(request)) | ||
| 316 | } | 314 | } | ||
| 317 | 315 | ||||
| 318 | - | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |||
| 319 | - | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 320 | - | require.NoError(t, err) | |||
| 321 | - | ||||
| 322 | - | err = validateTranscodeSourceURL(context.Background(), privateURL) | |||
| 323 | - | require.Error(t, err) | |||
| 324 | - | require.Contains(t, err.Error(), "private src host") | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 328 | - | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |||
| 329 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 330 | - | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |||
| 331 | - | }) | |||
| 332 | - | } | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | 316 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | ||
| 336 | request := cefmocks.NewMockRequest(t) | 317 | request := cefmocks.NewMockRequest(t) | ||
| 337 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 318 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||