diff --git a/go.mod b/go.mod index b91a337d..fec5b81f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( go.mau.fi/util v0.10.2-0.20260918225449-a4c0d5b86aa8 golang.org/x/crypto v0.57.0 golang.org/x/image v0.23.0 + golang.org/x/net v0.59.0 maunium.net/go/mautrix v0.31.1-0.20261012132636-34107524d2ab ) @@ -37,7 +38,6 @@ require ( github.com/yuin/goldmark v1.8.6 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 768604e0..04c4dd99 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -120,6 +120,9 @@ func obsTalkMetaMessageID(messageID string, isPlainMedia bool) string { } func mediaDownloadFailure(kind string, err error, relatesTo *event.RelatesTo) (*bridgev2.ConvertedMessage, error) { + if errors.Is(err, line.ErrOBSObjectTooLarge) { + return oversizedFileNotice(relatesTo), nil + } if !errors.Is(err, line.ErrOBSObjectNotFound) { // Keep ambiguous OBS failures retryable. Returning ErrIgnoringRemoteEvent // prevents bridgev2 from posting a generic error notice, while omitting a diff --git a/pkg/connector/handlers/media_size.go b/pkg/connector/handlers/media_size.go index 3f9966ea..3f856c55 100644 --- a/pkg/connector/handlers/media_size.go +++ b/pkg/connector/handlers/media_size.go @@ -33,6 +33,10 @@ func (h *Handler) oversizedMediaNotice(size int64, sizeSource string, relatesTo Int("limit_bytes", BeeperMaxFileSize). Str("size_source", sizeSource). Msg("Skipping oversized LINE media upload") + return oversizedFileNotice(relatesTo) +} + +func oversizedFileNotice(relatesTo *event.RelatesTo) *bridgev2.ConvertedMessage { return &bridgev2.ConvertedMessage{ Parts: []*bridgev2.ConvertedMessagePart{ { diff --git a/pkg/line/client.go b/pkg/line/client.go index 6740f1e3..f69bfcb0 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -39,6 +39,7 @@ var ( ErrOBSObjectNotFound = errors.New("LINE OBS object not found") ErrOBSEncodingIncomplete = errors.New("LINE OBS object encoding incomplete") ErrOBSObjectValidation = errors.New("LINE OBS object validation failed") + ErrOBSObjectTooLarge = errors.New("LINE OBS object exceeds the size limit") ) type Client struct { @@ -807,33 +808,46 @@ func (c *Client) DownloadOBSWithSIDOptions(ctx context.Context, oid string, mess return c.downloadOBSWithServiceAndSIDOptions(ctx, "talk", sid, oid, messageID, opts) } -// DownloadOBSPublicResource downloads a public OBS resource referenced by -// message content metadata. LINE Chrome uses DOWNLOAD_URL directly and skips -// object_info.obs and the private OBS authorization header mapper. -func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath string) ([]byte, error) { - parsedPath, err := url.Parse(resourcePath) +// DownloadOBSPublicResource downloads a public resource referenced by +// message content metadata. LINE Chrome uses DOWNLOAD_URL directly, as a full +// URL or as a path on the OBS host, and skips object_info.obs and the private +// OBS authorization header mapper. +func (c *Client) DownloadOBSPublicResource(ctx context.Context, resource string) ([]byte, error) { + parsed, err := url.Parse(resource) if err != nil { - return nil, fmt.Errorf("failed to parse public OBS resource path: %w", err) + return nil, fmt.Errorf("failed to parse public OBS resource: %w", err) } - if parsedPath.IsAbs() || parsedPath.Host != "" || !strings.HasPrefix(parsedPath.Path, "/") { - return nil, errors.New("public OBS resource must be an absolute path") + target := OBSBaseURL + resource + httpClient := c.obsHTTPClient() + if parsed.IsAbs() { + target = resource + httpClient = c.publicMediaHTTPClient() + } else if parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") { + return nil, errPublicMediaPolicy } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, OBSBaseURL+resourcePath, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, fmt.Errorf("failed to create public OBS resource request: %w", err) } req.Header.Set("User-Agent", UserAgent) - resp, err := c.obsHTTPClient().Do(req) + resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("public OBS resource request failed: %w", err) } - body, readErr := io.ReadAll(resp.Body) + reader := io.Reader(resp.Body) + if parsed.IsAbs() { + reader = io.LimitReader(resp.Body, publicMediaMaxBytes+1) + } + body, readErr := io.ReadAll(reader) resp.Body.Close() if readErr != nil { return nil, fmt.Errorf("failed to read public OBS resource response: %w", readErr) } + if int64(len(body)) > publicMediaMaxBytes { + return nil, ErrOBSObjectTooLarge + } switch resp.StatusCode { case http.StatusOK: return body, nil @@ -842,7 +856,7 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath str case http.StatusNotFound: return nil, ErrOBSObjectNotFound default: - return nil, newHTTPError(resp.StatusCode, "public OBS resource download failed (%d): %s", resp.StatusCode, string(body)) + return nil, newHTTPError(resp.StatusCode, "public OBS resource download failed (%d): %s", resp.StatusCode, string(body[:min(len(body), 512)])) } } diff --git a/pkg/line/obs_test.go b/pkg/line/obs_test.go index 2c1ec95a..70cde903 100644 --- a/pkg/line/obs_test.go +++ b/pkg/line/obs_test.go @@ -116,10 +116,10 @@ func TestDownloadOBSPublicResourceMatchesChromeRequestFlow(t *testing.T) { } } -func TestDownloadOBSPublicResourceRejectsExternalURL(t *testing.T) { +func TestDownloadOBSPublicResourceRejectsUnsafeURL(t *testing.T) { client := NewClient("line-token") for _, resourcePath := range []string{ - "https://example.com/image", + "http://127.0.0.1/image", "//example.com/image", "relative/image", } { diff --git a/pkg/line/publicmedia.go b/pkg/line/publicmedia.go new file mode 100644 index 00000000..afbcf98f --- /dev/null +++ b/pkg/line/publicmedia.go @@ -0,0 +1,138 @@ +package line + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "net/netip" + "time" + + "golang.org/x/net/idna" +) + +const ( + publicMediaMaxBytes = 100*1024*1024 + 32 + publicMediaTimeout = 45 * time.Second +) + +var errPublicMediaPolicy = errors.New("unsupported LINE public media URL") + +type publicMediaTransport struct { + base *http.Transport +} + +func (transport publicMediaTransport) RoundTrip(request *http.Request) (*http.Response, error) { + endpoint := request.URL + port := "443" + if endpoint.Scheme == "http" { + port = "80" + } + if endpoint.Scheme != "https" && endpoint.Scheme != "http" || endpoint.Hostname() == "" || endpoint.Port() != "" && endpoint.Port() != port || endpoint.User != nil || endpoint.Fragment != "" { + return nil, errPublicMediaPolicy + } + host := endpoint.Hostname() + if _, err := netip.ParseAddr(host); err != nil { + host, err = idna.Lookup.ToASCII(host) + if err != nil { + return nil, errPublicMediaPolicy + } + } + addresses, err := net.DefaultResolver.LookupNetIP(request.Context(), "ip", host) + if err != nil || len(addresses) == 0 { + return nil, errors.New("could not resolve LINE public media host") + } + for _, address := range addresses { + if !publicMediaAddress(address) { + return nil, errPublicMediaPolicy + } + } + client := transport.base.Clone() + defer client.CloseIdleConnections() + client.DisableKeepAlives = true + if client.TLSClientConfig == nil { + client.TLSClientConfig = &tls.Config{} + } else { + client.TLSClientConfig = client.TLSClientConfig.Clone() + } + client.TLSClientConfig.ServerName = host + if client.Proxy != nil { + proxy, err := client.Proxy(request) + if err != nil { + return nil, err + } + client.Proxy = http.ProxyURL(proxy) + if proxy != nil && proxy.Scheme == "https" && client.DialTLSContext == nil { + dial := client.DialContext + if dial == nil { + dial = (&net.Dialer{}).DialContext + } + client.DialTLSContext = func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := dial(ctx, network, address) + if err != nil { + return nil, err + } + config := client.TLSClientConfig.Clone() + config.ServerName = proxy.Hostname() + config.NextProtos = nil + return tls.Client(conn, config), nil + } + } + } + for _, address := range addresses { + pinned := request.Clone(request.Context()) + pinned.URL.Host = net.JoinHostPort(address.String(), port) + pinned.Host = endpoint.Host + var response *http.Response + response, err = client.RoundTrip(pinned) + if err == nil { + response.Request = request + return response, nil + } + if request.Context().Err() != nil { + return nil, request.Context().Err() + } + } + return nil, err +} + +func publicMediaAddress(address netip.Addr) bool { + address = address.Unmap() + if !address.IsGlobalUnicast() || address.IsPrivate() || address.IsLoopback() || address.IsLinkLocalUnicast() { + return false + } + if netip.MustParsePrefix("64:ff9b::/96").Contains(address) { + bytes := address.As16() + return publicMediaAddress(netip.AddrFrom4([4]byte(bytes[12:]))) + } + if address.Is6() && !netip.MustParsePrefix("2000::/3").Contains(address) { + return false + } + for _, prefix := range []string{"0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", "2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20"} { + if netip.MustParsePrefix(prefix).Contains(address) { + return false + } + } + return true +} + +func (c *Client) publicMediaHTTPClient() *http.Client { + base, ok := http.DefaultTransport.(*http.Transport) + if obsTransport, isHTTP := c.obsHTTPClient().Transport.(*http.Transport); isHTTP { + base, ok = obsTransport, true + } + if !ok { + base = &http.Transport{} + } + return &http.Client{ + Timeout: publicMediaTimeout, + Transport: publicMediaTransport{base: base}, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errPublicMediaPolicy + } + return nil + }, + } +}