{"record":{"id":"6baf256f58ced1ee","repo":"Tencent/WeKnora","slug":"attachment-url-rejected-w","errorCode":null,"errorMessage":"attachment URL rejected: %w","messagePattern":"attachment URL rejected: %w","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/datasource/connector/notion/client.go","lineNumber":383,"sourceCode":"func (c *notionClient) ResolveBlock(ctx context.Context, blockID string) (*notionBlock, error) {\n\trespBody, err := c.doRequest(ctx, http.MethodGet, \"/v1/blocks/\"+blockID, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar block notionBlock\n\tif err := json.Unmarshal(respBody, &block); err != nil {\n\t\treturn nil, fmt.Errorf(\"unmarshal block: %w\", err)\n\t}\n\treturn &block, nil\n}\n\nconst maxDownloadSize = 100 * 1024 * 1024 // 100MB — prevent OOM from oversized files\n\n// DownloadFile downloads a file from the given URL (typically an S3 signed URL).\n// Does not go through the rate limiter since it's not a Notion API call.\nfunc (c *notionClient) DownloadFile(ctx context.Context, fileURL string) ([]byte, error) {\n\tif err := utils.ValidateURLForSSRF(fileURL); err != nil {\n\t\treturn nil, fmt.Errorf(\"attachment URL rejected: %w\", err)\n\t}\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"create download request: %w\", err)\n\t}\n\n\tvar lastErr error\n\tfor attempt := 0; attempt <= maxRetries; attempt++ {\n\t\tresp, err := c.httpClient.Do(req)\n\t\tif err != nil {\n\t\t\tlastErr = err\n\t\t\tif attempt < maxRetries {\n\t\t\t\tif sErr := sleepWithContext(ctx, time.Duration(1<<attempt)*time.Second); sErr != nil {\n\t\t\t\t\treturn nil, sErr\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tbreak","sourceCodeStart":365,"sourceCodeEnd":401,"githubUrl":"https://github.com/Tencent/WeKnora/blob/988cbb03305e055d8ebb7d46d9ac6cc0803cd074/internal/datasource/connector/notion/client.go#L365-L401","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","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.","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.","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."],"exampleFix":"// before\nclient.DownloadFile(ctx, \"http://192.168.1.10/files/doc.pdf\")\n// after\nclient.DownloadFile(ctx, block.File.File.URL) // public S3 signed URL from the Notion block","handlingStrategy":"validation","validationCode":"u, err := url.Parse(fileURL)\nif err != nil || (u.Scheme != \"https\" && u.Scheme != \"http\") {\n    return fmt.Errorf(\"attachment url must be absolute http(s): %q\", fileURL)\n}\nif ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast()) {\n    return fmt.Errorf(\"attachment url points at private address\")\n}","typeGuard":"func isPublicHTTPURL(raw string) bool {\n    u, err := url.Parse(raw)\n    if err != nil || u.Hostname() == \"\" {\n        return false\n    }\n    if u.Scheme != \"https\" && u.Scheme != \"http\" {\n        return false\n    }\n    ip := net.ParseIP(u.Hostname())\n    return ip == nil || !(ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast())\n}","tryCatchPattern":"data, err := client.DownloadFile(ctx, fileURL)\nif err != nil {\n    var rejection = \"attachment URL rejected\"\n    if strings.Contains(err.Error(), rejection) {\n        logger.Warnf(ctx, \"skipping attachment %s: SSRF validation failed\", fileURL)\n        return nil // do not retry; the URL is not fetchable by policy\n    }\n    return err\n}","preventionTips":["Only download from the signed URLs Notion returns in block payloads; never accept user-supplied attachment URLs.","Re-resolve file_upload blocks (ResolveBlock) instead of reconstructing or mirroring URLs yourself.","Treat rejection errors as permanent — do not retry them."],"tags":["security","ssrf","download","notion"],"backgroundTag":"ssrf-url-blocked","analyzedSha":"988cbb03305e055d8ebb7d46d9ac6cc0803cd074","analyzedAt":"2026-09-02T14:41:08.344Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T15:18:49.778Z"}