Tencent/WeKnora · error

attachment URL rejected: %w

Error message

attachment URL rejected: %w

What it means

DownloadFile downloads an attachment (typically a Notion S3 signed URL) but first runs the URL through utils.ValidateURLForSSRF, which blocks URLs pointing at private/loopback/link-local addresses or disallowed schemes. If validation fails, the download is refused with 'attachment URL rejected' wrapping the SSRF validator's reason. This is a deliberate security guard against server-side request forgery via attacker-controllable attachment URLs.

Source

Thrown at internal/datasource/connector/notion/client.go:383

func (c *notionClient) ResolveBlock(ctx context.Context, blockID string) (*notionBlock, error) {
	respBody, err := c.doRequest(ctx, http.MethodGet, "/v1/blocks/"+blockID, nil)
	if err != nil {
		return nil, err
	}
	var block notionBlock
	if err := json.Unmarshal(respBody, &block); err != nil {
		return nil, fmt.Errorf("unmarshal block: %w", err)
	}
	return &block, nil
}

const maxDownloadSize = 100 * 1024 * 1024 // 100MB — prevent OOM from oversized files

// DownloadFile downloads a file from the given URL (typically an S3 signed URL).
// Does not go through the rate limiter since it's not a Notion API call.
func (c *notionClient) DownloadFile(ctx context.Context, fileURL string) ([]byte, error) {
	if err := utils.ValidateURLForSSRF(fileURL); err != nil {
		return nil, fmt.Errorf("attachment URL rejected: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
	if err != nil {
		return nil, fmt.Errorf("create download request: %w", err)
	}

	var lastErr error
	for attempt := 0; attempt <= maxRetries; attempt++ {
		resp, err := c.httpClient.Do(req)
		if err != nil {
			lastErr = err
			if attempt < maxRetries {
				if sErr := sleepWithContext(ctx, time.Duration(1<<attempt)*time.Second); sErr != nil {
					return nil, sErr
				}
				continue
			}
			break

View on GitHub (pinned to 988cbb0330)

Solutions

  1. Inspect the wrapped validator message to see which rule fired (scheme, IP range, or hostname) and confirm the attachment URL is a legitimate public Notion/S3 signed URL.
  2. Re-sync the page so Notion re-resolves file_upload blocks into fresh, valid S3 URLs (ResolveBlock); stale or hand-crafted URLs are the usual culprit.
  3. If the file is genuinely on an internal mirror, download it outside this library or add a narrowly scoped, reviewed allowance to the SSRF validator config.
  4. Never bypass the SSRF check for user-supplied URLs — if you must fetch an internal asset, do it in a separate, network-isolated worker with an explicit allowlist.

Example fix

// before
client.DownloadFile(ctx, "http://192.168.1.10/files/doc.pdf")
// after
client.DownloadFile(ctx, block.File.File.URL) // public S3 signed URL from the Notion block
Defensive patterns

Strategy: validation

Validate before calling

u, err := url.Parse(fileURL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
    return fmt.Errorf("attachment url must be absolute http(s): %q", fileURL)
}
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {
    return fmt.Errorf("attachment url points at private address")
}

Type guard

func isPublicHTTPURL(raw string) bool {
    u, err := url.Parse(raw)
    if err != nil || u.Hostname() == "" {
        return false
    }
    if u.Scheme != "https" && u.Scheme != "http" {
        return false
    }
    ip := net.ParseIP(u.Hostname())
    return ip == nil || !(ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast())
}

Try / catch

data, err := client.DownloadFile(ctx, fileURL)
if err != nil {
    var rejection = "attachment URL rejected"
    if strings.Contains(err.Error(), rejection) {
        logger.Warnf(ctx, "skipping attachment %s: SSRF validation failed", fileURL)
        return nil // do not retry; the URL is not fetchable by policy
    }
    return err
}

Prevention

When it happens

Trigger: Calling DownloadFile with a fileURL that resolves to a private IP (10.x, 192.168.x, 127.0.0.1, 169.254.169.254 metadata endpoints), a non-http(s) scheme (file://, gopher://), or a hostname that fails the SSRF validator — often because a page/block contains a forged or non-Notion external file URL.

Common situations: Notion pages containing external file links (not S3-uploaded files) that point at internal hosts; self-hosted environments where attachment storage was remapped to an internal mirror; DNS that resolves a public-looking hostname to a private address; accidentally passing a local file path as the URL.

Related errors


AI-assisted analysis of Tencent/WeKnora@988cbb0330 (2026-09-02). Data as JSON: /api/errors/6baf256f58ced1ee. Report an issue: GitHub.