Files
tessera-ctl/.planning/quick/260707-lgh-favoriten-widget-icon-proxy-fuer-cross-o/260707-lgh-PLAN.md
T
schalli 010aceb1ac
Tessera CI/CD / Lint & Type Check (push) Successful in 41s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m40s
feat(ldap): support anonymous bind (no bind DN/password required)
bindDn and bindPassword are now optional on LdapConfig (nullable
migration) and throughout the DTOs/service/client -- an admin can
leave both blank to connect to directories that permit anonymous
read access. LdapService.bind() falls back to an RFC 4513 anonymous
bind (empty DN + empty password) whenever either field is missing,
shared across testConnection, listGroups, and syncUsersForTenant.

Frontend: removed the required attribute from Bind-DN/Bind-Passwort,
added a placeholder hint ("leer = anonymous bind"), and the
"Verbindung testen" button now only needs a Server-URL to enable
(not bindDn+bindPassword). Config responses now return bindPassword
as null (not a misleading "********") when no password is set.

Verified locally: submitted only a Server-URL with both bind fields
empty and confirmed the request reached the anonymous-bind code path
(DNS failure for the unreachable test host, not a validation error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 12:57:26 +02:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
quick-260707-lgh 01 execute 1
apps/api/src/favorites/icon-discovery.service.ts
apps/api/src/favorites/icon-discovery.service.spec.ts
apps/api/src/favorites/favorites.service.ts
apps/api/src/favorites/favorites.controller.ts
apps/web/src/components/dashboard/widgets/favorites-widget.tsx
true
QUICK-FAV-ICON-PROXY
truths artifacts key_links
Favoriten-Icons of sites that send Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai) render in the widget because the <img> is now same-origin
The icon-serving endpoint only accepts a FavoriteLink id, never an arbitrary URL — no open SSRF proxy is introduced
The endpoint returns the icon only for a row owned by the authenticated user; other users' rows return 404 without leaking existence
Fetch failures (unreachable, timeout, non-image content-type, SSRF-blocked, no icon on record) return a real HTTP error status, not a 200 with garbage body
The existing discoverFavoriteIconUrl creation-time flow is unchanged
apps/api/src/favorites/favorites.controller.ts — new GET :id/icon route
apps/api/src/favorites/icon-discovery.service.ts — exported shared SSRF validation + byte-fetch method
apps/api/src/favorites/favorites.service.ts — ownership-scoped icon lookup
apps/web/src/components/dashboard/widgets/favorites-widget.tsx — <img src> pointing at same-origin proxy route
favorites-widget <img src> -> /api-proxy/favorites/:id/icon -> FavoritesController -> FavoritesService (userId scope) -> IconDiscoveryService (SSRF-guarded byte fetch) -> stored iconUrl
shared isPublicHttpUrl / redirect-guard reused by BOTH discoverFavoriteIconUrl (HTML) and the new byte-fetch path
Fix the Favoriten widget so icons from sites that send `Cross-Origin-Resource-Policy: same-origin` (e.g. claude.ai) render. Chrome blocks the cross-origin hotlinked `` with `net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin`. Stop hotlinking: serve the icon bytes through the Tessera API so the `` becomes same-origin.

Purpose: Restore visible favicons for CORP-protected sites without weakening SSRF posture or altering the creation-time icon-discovery flow. Output: A new GET /favorites/:id/icon endpoint that streams the stored icon bytes for a row owned by the authenticated user (SSRF-guarded, size/timeout/content-type capped), and a frontend change pointing <img src> at that same-origin route via the existing /api-proxy rewrite.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @./CLAUDE.md @apps/api/src/favorites/favorites.controller.ts @apps/api/src/favorites/favorites.service.ts @apps/api/src/favorites/icon-discovery.service.ts @apps/web/src/components/dashboard/widgets/favorites-widget.tsx @apps/web/next.config.ts

Interface facts (already read — do not re-derive):

  • Controller scopes requests via extractContext(req) returning { userId, tenantId }; ownership on other routes (update/remove) is enforced in the service by matching link.userId !== userId and throwing NotFoundException. Match this exact pattern.
  • FavoriteLink has userId, tenantId, iconUrl String?. The service's existing ownership check compares userId only — follow that (do NOT invent a tenantId-based check).
  • icon-discovery.service.ts currently has module-private isPublicHttpUrl, isPrivateIpAddress, isBlockedHostname, and the manual-redirect loop fetchHtml (redirect: 'manual', per-hop re-validation, 4000ms AbortController timeout, MAX_HTML_CHARS cap). These are the primitives to share.
  • Frontend: <img src={fav.iconUrl}> is at favorites-widget.tsx around lines 361-373, guarded by {fav.iconUrl && (...)} with an existing onError handler that hides the img. Client API calls use API_URL (favorites-api.ts) which resolves to the API origin; the browser reaches the API through the /api-proxy/:path* rewrite in next.config.ts.
Task 1: Share SSRF validation and add an SSRF-guarded icon byte-fetch to IconDiscoveryService apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/favorites/icon-discovery.service.spec.ts - isPublicHttpUrl (now exported) returns false for private IPv4 (10.x, 127.x, 192.168.x, 169.254.x), blocked hostnames (localhost, .local, 0.0.0.0), and non-http(s) protocols; returns true for a public host. - fetchIconBytes rejects when the upstream Content-Type is not an image/* type. - fetchIconBytes rejects when the SSRF guard fails (private/blocked target). - fetchIconBytes enforces a byte-size cap (1MB) and rejects an oversized body. - fetchIconBytes returns { contentType, body } for a valid image response. - discoverFavoriteIconUrl behaviour is unchanged (still returns a URL string, still falls back to origin favicon). Refactor the module-private SSRF primitives so they are reusable by both the existing HTML-discovery path and the new byte-fetch path — do NOT duplicate the logic. (1) Export `isPublicHttpUrl` from the module. (2) Extract the manual-redirect fetch loop currently embedded in `fetchHtml` into a shared internal helper (e.g. `fetchWithRedirectGuard(url, { accept, timeoutMs })`) that: uses `redirect: 'manual'`, re-validates every hop with `isPublicHttpUrl`, applies an `AbortController` timeout, follows up to the existing `MAX_REDIRECTS`, and returns the final non-redirect `Response` (or null on any block/failure). Rewrite `fetchHtml` to call this helper and keep its existing HTML content-type check and `MAX_HTML_CHARS` slice — its external behaviour must not change. (3) Add a public method `fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }>` on `IconDiscoveryService`. It parses the URL, fetches via `fetchWithRedirectGuard` with an image Accept header and a dedicated timeout constant (mirror the existing `HTML_FETCH_TIMEOUT_MS` style, e.g. `ICON_FETCH_TIMEOUT_MS`), then: verify the response `Content-Type` starts with `image/` (case-insensitive) — throw if not; read the body while enforcing a `MAX_ICON_BYTES` cap (1MB, mirroring the `MAX_HTML_CHARS` pattern) — throw if exceeded; return `{ contentType, body }`. On any failure (guard block, timeout, non-image, oversized, network error) throw a plain `Error` — do NOT return a placeholder. Leave `discoverFavoriteIconUrl` untouched. Create the spec file exercising the behaviours listed above. Use vitest globals (config already sets `globals: true`). Stub `global.fetch` with `vi.fn()` to return controlled Response-like objects for the content-type / size / success cases; for the SSRF cases, assert `isPublicHttpUrl` directly against literal private/blocked URLs (no network). Do not make real network calls. pnpm --filter @tessera/api exec vitest run src/favorites/icon-discovery.service.spec.ts Spec passes. `isPublicHttpUrl` is exported and reused by both paths. `fetchIconBytes` exists with image-content-type check, 1MB cap, timeout, and SSRF guard. `discoverFavoriteIconUrl` signature and fallback behaviour unchanged. Task 2: Add ownership-scoped icon lookup in the service and a streaming GET :id/icon endpoint apps/api/src/favorites/favorites.service.ts, apps/api/src/favorites/favorites.controller.ts Service: add `async getIconBytes(id: string, userId: string): Promise<{ contentType: string; body: Buffer }>`. Load the row with `findUnique({ where: { id } })`. If `!link || link.userId !== userId` throw `NotFoundException` (identical to update/remove — do not leak existence, use the userId-only check, not tenantId). If `link.iconUrl` is null/empty, throw `NotFoundException` (no icon on record). Otherwise call `this.iconDiscovery.fetchIconBytes(link.iconUrl)`; if that throws (unreachable/timeout/non-image/SSRF-blocked), rethrow as a 502 by throwing `new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY)` (import `HttpException`, `HttpStatus` from `@nestjs/common`). Return the `{ contentType, body }`. Controller: add `@Get(':id/icon')` handler `getIcon(@Param('id') id, @Req() req, @Res() res: Response)`. Import `Res` from `@nestjs/common` and `Response` from express (Request is already imported). Resolve `const { userId } = this.extractContext(req)`, await `this.favoritesService.getIconBytes(id, userId)`, then set `res.setHeader('Content-Type', contentType)`, set a `Cache-Control` header allowing browser caching (e.g. `public, max-age=86400`), and `res.send(body)`. Let `NotFoundException` (404) and `HttpException` 502 propagate to Nest's exception filter — do NOT catch-and-200. Do not add a placeholder image. Keep the route distinct from the existing `:id` PATCH/DELETE (this is `GET :id/icon`, no clash). cd apps/api && pnpm type-check && grep -q "getIconBytes" src/favorites/favorites.service.ts && grep -q "':id/icon'" src/favorites/favorites.controller.ts && grep -q "BAD_GATEWAY" src/favorites/favorites.service.ts `GET /favorites/:id/icon` streams the stored icon bytes for a row owned by the caller with a Cache-Control header; not-owned/not-found/no-icon returns 404; upstream fetch failure returns 502. Type-check passes. Task 3: Point the widget img at the same-origin proxy route apps/web/src/components/dashboard/widgets/favorites-widget.tsx In `FavoriteTile`, change the icon `` so its `src` targets the new same-origin proxy endpoint instead of hotlinking `fav.iconUrl`. Build the src as `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon` (consistent with how browser-side API calls reach the API through the `/api-proxy/:path*` rewrite in next.config.ts). Keep the render guard exactly as today: still only render the `` when `fav.iconUrl` is truthy (that flag means "an icon URL is on record" even though the src now points at the proxy route). Keep the existing `onError` handler (hides the img, revealing the letter fallback) unchanged, plus `alt`, `width`, `height`, `loading="lazy"`, and className unchanged. Do not otherwise alter the widget. cd apps/web && pnpm type-check && grep -q "/api-proxy/favorites/" src/components/dashboard/widgets/favorites-widget.tsx && ! grep -q "src={fav.iconUrl}" src/components/dashboard/widgets/favorites-widget.tsx The widget's icon `` points at `/api-proxy/favorites/:id/icon`, still guarded by `fav.iconUrl` truthiness, onError fallback intact, and no longer references `src={fav.iconUrl}`. Web type-check passes.

<threat_model>

Trust Boundaries

Boundary Description
browser -> API (GET /favorites/:id/icon) Authenticated caller supplies a FavoriteLink id (not a URL)
API -> external favicon host Server-side outbound fetch of a stored, previously-discovered URL

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-QFIP-01 Information Disclosure (SSRF) GET :id/icon fetch target high mitigate Endpoint takes a FavoriteLink id, never a client-supplied URL. The URL fetched is the row's stored iconUrl loaded server-side — no arbitrary-URL proxy surface. All fetches go through the shared isPublicHttpUrl guard (private IP ranges, blocked hostnames, DNS resolution).
T-QFIP-02 Information Disclosure (SSRF redirect) fetchWithRedirectGuard high mitigate Redirects handled redirect: 'manual' with per-hop re-validation via isPublicHttpUrl and a bounded MAX_REDIRECTS — reused from the existing HTML path, not re-implemented.
T-QFIP-03 Information Disclosure (cross-user) getIconBytes ownership check high mitigate Row loaded then rejected with NotFoundException unless link.userId === userId; 404 (not 403) avoids leaking row existence — mirrors existing update/remove.
T-QFIP-04 Denial of Service (resource exhaustion) fetchIconBytes medium mitigate ICON_FETCH_TIMEOUT_MS AbortController timeout + MAX_ICON_BYTES 1MB body cap prevent slow-loris and large-body memory exhaustion, mirroring existing HTML timeout/MAX_HTML_CHARS caps.
T-QFIP-05 Tampering / Spoofing (content smuggling) fetchIconBytes content-type gate medium mitigate Response Content-Type must start with image/; non-image responses are rejected (502) rather than streamed to the browser.
T-QFIP-06 Denial of Service (error masking) controller error mapping low mitigate Failures return real 404/502 statuses (never 200 + garbage); the frontend onError handler then cleanly reveals the letter fallback.
</threat_model>
- `pnpm --filter @tessera/api exec vitest run src/favorites/icon-discovery.service.spec.ts` passes. - `cd apps/api && pnpm type-check` passes; `cd apps/web && pnpm type-check` passes. - Manual (optional): add a claude.ai favorite, load the dashboard, confirm the claude.ai icon now renders (network tab shows `/api-proxy/favorites//icon` returning 200 image), and a bogus/blocked target yields 404/502 with the letter fallback shown.

<success_criteria>

  • CORP-protected favicons (claude.ai) render because the <img> is same-origin.
  • No arbitrary-URL SSRF proxy exists — only id-based, ownership-scoped lookups.
  • SSRF validation is shared, not duplicated; discoverFavoriteIconUrl is unchanged.
  • Failures return 404/502; success carries a Cache-Control header. </success_criteria>
Create `.planning/quick/260707-lgh-favoriten-widget-icon-proxy-fuer-cross-o/260707-lgh-SUMMARY.md` when done