From ae27bf26c6f28c8d072a61e84583c020055a72d6 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 11 Nov 2026 13:04:58 -0500 Subject: [PATCH] fix(attachments): correct base64url encode in render-link hook (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hugo's `replace` is `replace INPUT OLD NEW`, so the pipe chain `$b64 | replace "+" "-" | replace "/" "_" | replace "=" ""` appended $b64 as the third (NEW) argument at each step, searching the literal "+"/"/"/"=" and discarding the base64 entirely. The emitted attachment-source param collapsed to "u=/=/", which the server base64url-decoded to an invalid URL — ingest failed and every tutorial download link returned 404 "Attachment unavailable" (old and new tutorials alike). Use nested replace() calls with the string as the first positional arg. Add a behavioral regression test that renders the real hook through Hugo and asserts the u= param base64url-decodes back to the exact source URL; the previous string-grep test never rendered Hugo, which is why the bug shipped. --- .../layouts/_default/_markup/render-link.html | 9 +- .../render-link-attachment-encode.test.ts | 116 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 test/unit/render-link-attachment-encode.test.ts diff --git a/hugo/layouts/_default/_markup/render-link.html b/hugo/layouts/_default/_markup/render-link.html index 51cf7f2c1..d050cbbb2 100644 --- a/hugo/layouts/_default/_markup/render-link.html +++ b/hugo/layouts/_default/_markup/render-link.html @@ -11,9 +11,14 @@ {{- $isAttachment := and (hasPrefix $dest "https://raw.githubusercontent.com/") (in $exts $ext) -}} {{- if $isAttachment -}} {{- /* Encode the raw GitHub URL as base64url (no colons = WAF-safe). - Hugo's base64Encode produces standard base64; convert to base64url by replacing +/= with -_. */ -}} + Hugo's base64Encode produces standard base64; convert to base64url by replacing + → -, / → _, and stripping = padding. + NOTE: Hugo's `replace` is `replace INPUT OLD NEW`. It MUST be called with the + string as the FIRST positional arg — a pipe chain (`$b64 | replace "+" "-"`) + appends $b64 as the THIRD arg (NEW), so each step searches the literal "+"/"/"/"=" + and discards the base64 entirely, collapsing the param to "u=/=/" (issue #3098). + Keep these as nested calls, not a pipe chain. */ -}} {{- $b64 := $dest | base64Encode -}} -{{- $b64url := $b64 | replace "+" "-" | replace "/" "_" | replace "=" "" -}} +{{- $b64url := replace (replace (replace $b64 "+" "-") "/" "_") "=" "" -}} {{- $view := printf "/content/attachment-source?u=%s" $b64url -}} {{- $dl := printf "/content/attachment-source?u=%s&dl=1" $b64url -}} {{- /* $view/$dl are server-constructed, server-relative /content/attachment-source paths using base64url diff --git a/test/unit/render-link-attachment-encode.test.ts b/test/unit/render-link-attachment-encode.test.ts new file mode 100644 index 000000000..3ea202280 --- /dev/null +++ b/test/unit/render-link-attachment-encode.test.ts @@ -0,0 +1,116 @@ +/* eslint-disable */ +// Regression test for issue #3098 — tutorial file downloads broken ("Attachment +// unavailable"). +// +// The render-link hook (hugo/layouts/_default/_markup/render-link.html) routes +// raw.githubusercontent.com attachment links through the CAP attachment store at +// /content/attachment-source?u=. The source URL is +// base64url-encoded to dodge the Akamai WAF (#1931 follow-up, b82e4bb21). +// +// The encode used a PIPE chain to strip base64 padding/url-unsafe chars: +// {{ $b64 | replace "+" "-" | replace "/" "_" | replace "=" "" }} +// But Hugo's `replace` is `replace INPUT OLD NEW` — piping appends $b64 as the +// THIRD argument (NEW), so each step became replace("+","-",$b64) etc., which +// searches the literal "+"/"/"/"=" and discards the base64 entirely. The emitted +// param collapsed to a garbage "u=/=/", so the server base64url-decoded it to an +// empty/invalid URL, ingest failed, and the endpoint returned 404 +// "Attachment unavailable" for EVERY download link (old and new tutorials alike). +// +// Live symptom: https://developers.sap.com/tutorials/ai-core-genai-hana-vector +// step 2 download → {"error":"Attachment unavailable"}, even though the file at +// raw.githubusercontent.com/.../GRAPH_DOCU_2503.csv is reachable. +// +// This spins up a throwaway Hugo site with ONLY the real render-link hook and a +// content page containing an attachment link, renders it, extracts the emitted +// u= param, and asserts it base64url-decodes back to the exact source URL. +// The old string-grep test never rendered Hugo, which is why the bug shipped. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..', '..'); +const HOOK = join(REPO_ROOT, 'hugo', 'layouts', '_default', '_markup', 'render-link.html'); + +// The exact link from issue #3098. +const SRC_URL = + 'https://raw.githubusercontent.com/SAP-samples/ai-core-samples/main/09_BusinessAIWeek/files/GRAPH_DOCU_2503.csv'; + +function hugoBinary(): string { + return process.env.HUGO || 'hugo'; +} + +let siteDir: string; +let rendered = ''; + +describe('render-link attachment hook encodes a decodable u= param (issue #3098)', () => { + beforeAll(() => { + siteDir = mkdtempSync(join(tmpdir(), 'render-link-attach-')); + mkdirSync(join(siteDir, 'layouts', '_default', '_markup'), { recursive: true }); + mkdirSync(join(siteDir, 'content'), { recursive: true }); + + // Use the REAL render-link hook from the repo — this is what we're guarding. + copyFileSync(HOOK, join(siteDir, 'layouts', '_default', '_markup', 'render-link.html')); + + writeFileSync(join(siteDir, 'hugo.toml'), + 'baseURL = "http://example.org/"\n' + + 'title = "t"\n' + + '[markup.goldmark.renderer]\n' + + 'unsafe = true\n'); + + writeFileSync(join(siteDir, 'layouts', '_default', 'single.html'), '{{ .Content }}\n'); + + writeFileSync(join(siteDir, 'content', 'page.md'), + '---\ntitle: p\n---\n\n' + + `The dataset in [GRAPH_DOCU_2503.csv](${SRC_URL}) includes vectors.\n`); + + const r = spawnSync(hugoBinary(), ['--source', siteDir], { + encoding: 'utf8', + timeout: 120_000, + }); + if (r.status !== 0) { + throw new Error( + `Hugo build failed (exit ${r.status})\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}` + ); + } + rendered = readFileSync(join(siteDir, 'public', 'page', 'index.html'), 'utf8'); + }, 130_000); + + afterAll(() => { + if (siteDir) rmSync(siteDir, { recursive: true, force: true }); + }); + + it('routes the attachment link through /content/attachment-source', () => { + expect(rendered).toContain('/content/attachment-source?u='); + expect(rendered).toContain('class="tutorial-attachment-link"'); + expect(rendered).toContain('class="tutorial-attachment-download"'); + }); + + it('does NOT emit the mangled "u=/=/" param (the #3098 bug)', () => { + expect(rendered).not.toContain('u=/=/'); + }); + + it('the view-link u= param base64url-decodes to the exact source URL', () => { + const m = rendered.match(/\/content\/attachment-source\?u=([^"&]+)"/); + expect(m, 'expected a view link with a u= param').toBeTruthy(); + const param = m![1]; + // base64url: no +, /, or = — all must have been converted/stripped. + expect(param).not.toMatch(/[+/=]/); + const decoded = Buffer.from(param, 'base64url').toString('utf8'); + expect(decoded).toBe(SRC_URL); + }); + + it('the download-link u= param base64url-decodes to the exact source URL', () => { + // The download sibling encodes to "...?u=&dl=1" in the HTML. + const m = rendered.match(/\/content\/attachment-source\?u=([^"&]+)&dl=1"/); + expect(m, 'expected a download link with a u= param and dl=1').toBeTruthy(); + const param = m![1]; + expect(param).not.toMatch(/[+/=]/); + const decoded = Buffer.from(param, 'base64url').toString('utf8'); + expect(decoded).toBe(SRC_URL); + }); +});