From f103f11d2bf1acd9edc849d2018a35fb16caba29 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 11:23:23 +0000 Subject: [PATCH 1/5] media: download full-URL DOWNLOAD_URL images like LINE Chrome --- go.mod | 2 +- pkg/line/client.go | 26 +++++--- pkg/line/obs_test.go | 4 +- pkg/line/publicmedia.go | 131 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 pkg/line/publicmedia.go 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/line/client.go b/pkg/line/client.go index 6740f1e3..3b9674f3 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -807,25 +807,31 @@ 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) } 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..d2f75616 --- /dev/null +++ b/pkg/line/publicmedia.go @@ -0,0 +1,131 @@ +package line + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "net/netip" + + "golang.org/x/net/idna" +) + +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 && client.DialTLS == 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{ + Transport: publicMediaTransport{base: base}, + CheckRedirect: func(_ *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errPublicMediaPolicy + } + return nil + }, + } +} From 341825458500a09149f6d92f604ebd8ce49960ef Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 11:36:10 +0000 Subject: [PATCH 2/5] media: cap size and time for full-URL image downloads --- pkg/line/client.go | 9 ++++++++- pkg/line/publicmedia.go | 12 +++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/pkg/line/client.go b/pkg/line/client.go index 3b9674f3..aff869aa 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -835,11 +835,18 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resource string) 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, errPublicMediaTooLarge + } switch resp.StatusCode { case http.StatusOK: return body, nil diff --git a/pkg/line/publicmedia.go b/pkg/line/publicmedia.go index d2f75616..e1bec05a 100644 --- a/pkg/line/publicmedia.go +++ b/pkg/line/publicmedia.go @@ -7,11 +7,20 @@ import ( "net" "net/http" "net/netip" + "time" "golang.org/x/net/idna" ) -var errPublicMediaPolicy = errors.New("unsupported LINE public media URL") +const ( + publicMediaMaxBytes = 100*1024*1024 + 32 + publicMediaTimeout = 45 * time.Second +) + +var ( + errPublicMediaPolicy = errors.New("unsupported LINE public media URL") + errPublicMediaTooLarge = errors.New("LINE public media exceeds the size limit") +) type publicMediaTransport struct { base *http.Transport @@ -120,6 +129,7 @@ func (c *Client) publicMediaHTTPClient() *http.Client { base = &http.Transport{} } return &http.Client{ + Timeout: publicMediaTimeout, Transport: publicMediaTransport{base: base}, CheckRedirect: func(_ *http.Request, via []*http.Request) error { if len(via) >= 3 { From 1bb009886a497c046280d7504582d8e6d1eda9fc Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 11:37:05 +0000 Subject: [PATCH 3/5] media: keep public resource error bodies short --- pkg/line/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/line/client.go b/pkg/line/client.go index aff869aa..c497481b 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -855,7 +855,7 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resource string) 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)])) } } From 625183fd5d409be494824d08b4c8a62deaa8d3e1 Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 11:44:01 +0000 Subject: [PATCH 4/5] media: drop deprecated DialTLS check --- pkg/line/publicmedia.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/line/publicmedia.go b/pkg/line/publicmedia.go index e1bec05a..c25c8d8b 100644 --- a/pkg/line/publicmedia.go +++ b/pkg/line/publicmedia.go @@ -66,7 +66,7 @@ func (transport publicMediaTransport) RoundTrip(request *http.Request) (*http.Re return nil, err } client.Proxy = http.ProxyURL(proxy) - if proxy != nil && proxy.Scheme == "https" && client.DialTLSContext == nil && client.DialTLS == nil { + if proxy != nil && proxy.Scheme == "https" && client.DialTLSContext == nil { dial := client.DialContext if dial == nil { dial = (&net.Dialer{}).DialContext From ef42052a6f9c8263f891735c3b7aca3de618238c Mon Sep 17 00:00:00 2001 From: Gerardo Rodriguez Date: Mon, 26 Oct 2026 22:09:23 +0000 Subject: [PATCH 5/5] media: show the 100MB notice for oversized full-URL images --- pkg/connector/handlers/handler.go | 3 +++ pkg/connector/handlers/media_size.go | 4 ++++ pkg/line/client.go | 3 ++- pkg/line/publicmedia.go | 5 +---- 4 files changed, 10 insertions(+), 5 deletions(-) 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 c497481b..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 { @@ -845,7 +846,7 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resource string) return nil, fmt.Errorf("failed to read public OBS resource response: %w", readErr) } if int64(len(body)) > publicMediaMaxBytes { - return nil, errPublicMediaTooLarge + return nil, ErrOBSObjectTooLarge } switch resp.StatusCode { case http.StatusOK: diff --git a/pkg/line/publicmedia.go b/pkg/line/publicmedia.go index c25c8d8b..afbcf98f 100644 --- a/pkg/line/publicmedia.go +++ b/pkg/line/publicmedia.go @@ -17,10 +17,7 @@ const ( publicMediaTimeout = 45 * time.Second ) -var ( - errPublicMediaPolicy = errors.New("unsupported LINE public media URL") - errPublicMediaTooLarge = errors.New("LINE public media exceeds the size limit") -) +var errPublicMediaPolicy = errors.New("unsupported LINE public media URL") type publicMediaTransport struct { base *http.Transport