Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pkg/connector/handlers/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/connector/handlers/media_size.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
{
Expand Down
38 changes: 26 additions & 12 deletions pkg/line/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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)]))
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/line/obs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
} {
Expand Down
138 changes: 138 additions & 0 deletions pkg/line/publicmedia.go
Original file line number Diff line number Diff line change
@@ -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
},
}
}
Loading