Weekly review batch: silent custom-slug failure, unsafe client-side interpolation, Python SDK InvalidURL escape, slug-repository invariant guard, unscannable QR codes, title-fetch SSRF guard - #59
Conversation
…tnrError httpx.InvalidURL (e.g. an unparsable port) is not a subclass of httpx.RequestError, so it escaped the try/except in every resource's _request/_request_text as a raw httpx exception instead of the documented ShrtnrError(status=0, ...). It's raised synchronously from request-building, before any I/O, so a malformed base_url trips it on the very first call. Widened the except clause in all 8 call sites (Links, Bundles, Slugs, sync and async) to also catch httpx.InvalidURL. Added a regression test per sync/async client asserting ShrtnrError(status=0) instead of a raw httpx.InvalidURL. Single-SDK change: this is an httpx-specific exception-hierarchy quirk. The TypeScript SDK's fetch-based error handling and the Dart SDK's http client don't have an equivalent split between a base request-error type and a synchronously-raised URL-validation error, so there's nothing to port. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ExTY6koRamRshQZ47rFPu9
…abled A link has exactly one system-generated (is_custom = 0) slug for its whole lifetime. disable()'s primary-fallback query promotes by is_custom = 0, so disabling that one row would have the query re-select the very row being demoted next, leaving the link with no primary slug at all: a violation of the single-primary invariant documented in src/slugs.ts. The only current caller (disableSlug in link-management.ts) already refuses to disable a non-custom slug before reaching the repository, so this was unreachable through the API, admin UI, or MCP. Added the same guard SlugRepository.remove() already has, so the repository enforces its own invariant rather than relying solely on that caller. Regression test calls SlugRepository.disable() directly on a link's only slug (bypassing the service-layer guard) and asserts it refuses, leaving the primary flag untouched. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ExTY6koRamRshQZ47rFPu9
client.ts's t() interpolated {param} placeholders with
val.replace(regex, String(params[k])), the plain-string form of
String.prototype.replace. That form treats $&, $`, $', $1 etc. in the
replacement string as special patterns instead of literal text.
src/i18n/index.ts's server-side createTranslateFn() already guards
against exactly this with a replacer function, but the client-side
reimplementation didn't.
Reachable with any $-containing free text passed as a param: an API key
title (client.confirmDeleteKey) or a bundle name (client.bundles.confirmArchive/
confirmDelete) both allow arbitrary characters. A title like 'Prod $$ key'
would show a confirm dialog reading 'Prod $ key'.
Fixed t() to pass a replacer function, mirroring the server-side fix.
Added a test extracting t() from the generated script and asserting
literal interpolation, mirroring the existing server-side i18n.test.ts
regression test for the same bug class.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExTY6koRamRshQZ47rFPu9
…ails
The 'New Link' modal's custom-slug branch showed the identical 'Link
created' toast whether the follow-up POST /links/:id/slugs (attaching the
requested custom slug) succeeded or failed:
if (!slugRes.ok) { toast(t('client.linkCreated')); }
else { toast(t('client.linkCreated')); }
A custom slug that's already taken or otherwise rejected failed silently:
the user was told their link (and its requested slug) was created, and was
redirected to the link detail page with no sign the slug never attached.
doAddSlug, the equivalent handler for adding a custom slug after the fact,
already reads the JSON error body and falls back to the existing
client.customError key on failure; this path never got the same treatment.
Extracted the custom-slug-attach step into its own attachCustomSlugAndGo()
function so it can read the failure body and toast client.customError (or
the server's message) without becoming a false positive for the
'unguarded res.json().then() toast' regression guard in
client-api-error-toasts.test.ts, which scans by balanced parens and would
otherwise flag the outer /links success handler that now nests the same
'.error'/'toast(' text.
Added regression tests driving createLink() against a stubbed api() that
succeeds on the link-create call and fails on the slug-attach call,
asserting the failure surfaces and the generic fallback fires when the
body carries no message; a third test confirms the success toast still
fires when the slug attaches.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExTY6koRamRshQZ47rFPu9
|
Flag for judgment: QR encoder uses wrong EC-codeword totals for versions 6-10, and never emits version-info blocks for 7-10 ( const eccL = [0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18];
const totalCodewords = [0, 26, 44, 70, 100, 134, 172, 196, 242, 292, 346];
const numEcc = eccL[ver];
const numData = totalCodewords[ver] - numEcc;
Reachable: custom slugs are allowed up to 128 characters, so Not fixing directly: a correct fix means implementing block splitting/interleaving per the spec plus version-info emission, which is a non-trivial rewrite of the hand-rolled encoder rather than a small, low-risk patch. Needs a decision on whether to fix the encoder properly, cap supported slug/URL length to stay within versions 1-5, or accept the current limitation. Flag for judgment: unrestricted server-side fetch of user-supplied link URLs for auto-labeling (SSRF pattern) (
This is a standard SSRF shape: no allow/deny policy exists in the codebase for the destination host. Cloudflare Workers' own network isolation (no direct route to RFC1918/loopback addresses unless the deployment adds a Tunnel or service binding) limits practical reach in most deployments, but nothing in this code enforces that — it depends entirely on platform topology. Not fixing directly: needs a product decision on policy (block RFC1918/loopback/link-local resolution, whether to also block redirects into such ranges, whether this should apply to all outbound fetches or just this one) rather than a one-line fix. Generated by Claude Code |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
shrtnr | bf4d709 | Sep 12 2026, 08:35 AM |
makeQR() produced undecodable symbols for any payload over 106 bytes, which a short link with a long custom slug plus ?utm_medium=qr reaches. Three defects stacked up, each confirmed against a real decoder (jsQR): - Versions 6-10 must split codewords into 2 (v6-9) or 4 (v10) Reed-Solomon blocks and interleave them byte by byte. The encoder treated the per-block ECC count as the total and ran one RS pass over the whole payload, so the ECC bytes never matched what a reader expects, and the data-codeword count was wrong too. - Versions 7-10 must carry two 18-bit BCH-protected version-information blocks next to the finders. The encoder never reserved or wrote them, so data modules landed where a reader looks for the version. - From version 7 on, alignment patterns also sit on the timing pattern's row and column. The "skip if reserved" test meant for the three finder overlaps also skipped those, so the symbol lacked alignment marks. Tests now rasterize each version's output and decode it with jsQR (new dev dependency), assert the version-info bits against ISO/IEC 18004 Table D.1, and check the 271-byte capacity boundary. Before the fix versions 6-10 failed to decode; after it every payload length from 1 to 271 bytes round-trips. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017a43SYLJdvdDhMcgJF4zXM
Link URLs are user-supplied, and autoLabelLink() fetched them server-side with no restriction and redirect: "follow". That is a server-side request forgery surface: a link at a loopback, RFC 1918, link-local or cloud-metadata address (or a public page redirecting to one) had the Worker fetch it and store the response's <title> where the link's owner can read it. Cloudflare's egress isolation blocks most such destinations already; the code now enforces it instead of assuming it. isPublicHttpUrl() admits only http(s) URLs whose host is a public name or a public IP literal. It rejects localhost and *.localhost, *.internal and *.local names, IPv4 loopback, unspecified, RFC 1918, shared address space, link-local, protocol-assignment, benchmarking, multicast and reserved ranges, and IPv6 loopback, unspecified, unique-local, link-local, site-local and multicast. IPv4-mapped and NAT64 IPv6 literals are judged by the embedded IPv4 address. Alternate IPv4 spellings (decimal integer, hex or octal octets) need no special case: the URL parser normalizes them to dotted quads first. fetchPageTitle() now fetches with redirect: "manual" and follows up to five hops itself, running the same check on each Location before requesting it. Hostnames are not resolved (Workers expose no DNS), so a public name pointing at a private address is left to the platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017a43SYLJdvdDhMcgJF4zXM
|
Both items above are now addressed on this branch, each with its own regression test and CI green on the resulting head:
Leaving this comment as the record of what the original findings were and how they were resolved. Generated by Claude Code |
DennisAlund
left a comment
There was a problem hiding this comment.
Code review: PR #59
Reviewed all 6 commits at bf4d709. 14 findings, 12 filed inline below. The two that follow could not be anchored: one file is untouched by this PR, the other line falls outside the diff hunks.
I verified the headline findings by running code rather than reading it: decoded QR payloads of every length 1 to 271 with jsQR, ran isPublicHttpUrl against 16 probe URLs, and checked Uri.parse against the real Dart toolchain.
Highest priority
- SSRF guard, trailing-dot bypass (
src/title-fetch.ts:45).http://metadata.google.internal./passes. - SSRF guard, IPv6 gaps (
src/title-fetch.ts:105).::a.b.c.dand2002::/16wrap loopback and link-local addresses past the check. - Dart SDK parity (below). The defect this PR fixed in Python is still live in Dart.
- The custom-slug toast is destroyed by the redirect that follows it (
src/client.ts:280). The PR's headline fix does not reach the user.
Not anchorable, 1: Dart SDK carries the same InvalidURL escape, so the single-SDK rationale does not hold
sdk/dart/lib/src/base_client.dart:170. CLAUDE.md: "Any change to one SDK under sdk/ must be evaluated and applied to the others." The commit message argues there is nothing to port. There is.
_buildUri() calls Uri.parse('$_baseUrl$path') at lines 172, 177 and 181, outside any try. Both call sites (requestJson line 60, requestText line 131) invoke it before the try { streamed = await _httpClient.send(request); } catch (e) { throw ShrtnrError(0, ...) } block that starts at line 77.
Confirmed against dart 3.10.4, using the exact input the new Python test uses:
Uri.parse('https://example.com:notaport/links')
-> FormatException: Invalid port (at character 21)
So ShrtnrClient(baseUrl: 'https://example.com:notaport', apiKey: k).links.list() throws a raw FormatException, breaking sdk/dart/README.md:142: "Network failures also throw ShrtnrError with status: 0."
Fix: wrap the _buildUri call in both requestJson and requestText. The TypeScript SDK is worth the same check before this merges.
Not anchorable, 2: non-ASCII QR payloads
Filed as a file-level comment on src/qr.ts, because the offending line 13 sits outside this PR's hunks.
Process gate
CLAUDE.md: "Any change under src/pages/, src/admin/, src/client.ts, src/styles.ts or src/index.tsx admin routes ends with yarn e2e green, not only yarn test. A code review or release that touches those paths is not complete until the e2e suite has run."
This PR changes src/client.ts and reports 15 e2e failures. Reproducing on main is not the same as green. The gap is load-bearing here: finding 4 above is exactly the class of defect the rule exists for, since a render test cannot see a toast that a navigation destroys. e2e/links.spec.ts also has no m-custom step at all, so the changed create path has no browser coverage.
Remaining findings, by weight
src/title-fetch.ts:140: redirect loop gives each hop its own 5s timeout, raising the worst case to 30s of attacker-controlledwaitUntil.src/db/slug-repository.ts:103: the guard blocksdisable()instead of fixing the fallback query, and overloads thenullreturn.src/qr.ts:236: GF(256) tables rebuilt per Reed-Solomon block, now 2 to 4 times per code.sdk/python/src/shrtnr/resources/links.py:55: the same_requestbody copy-pasted into 8 methods, with_base.pysitting right there.src/__tests__/unit/qr.test.ts:143: the version-information test recomputes the writer's own formula.src/__tests__/unit/client-custom-slug-toast.test.ts:16:extractTopLevelChunknow duplicated four times.sdk/python/tests/test_async_client.py:95: the new async test never closes its client.src/__tests__/unit/client-t-replacer.test.ts:11: em dash in a new comment.
🤖 Generated with Claude Code
| const octets = parseIPv4(host); | ||
| if (octets) return !isReservedIPv4(octets); | ||
|
|
||
| if (host === "localhost" || host.endsWith(".localhost")) return false; |
There was a problem hiding this comment.
A trailing dot bypasses every name-based block in this guard.
The WHATWG URL parser keeps a fully qualified hostname verbatim, so new URL("http://metadata.google.internal./computeMetadata/v1/").hostname is "metadata.google.internal.". That misses host === "localhost", .endsWith(".localhost"), .endsWith(".internal") and .endsWith(".local") alike, while DNS resolves name. to the same address as name.
Running the shipped function, all four of these return true:
http://metadata.google.internal./computeMetadata/v1/
http://localhost./
http://db.localhost./
http://printer.local./
The cloud metadata endpoint the doc comment names is reachable through the guard.
Fix: strip one trailing . from host before the comparisons. Numeric literals need no change, the parser already normalizes http://127.0.0.1./ to 127.0.0.1.
| } | ||
|
|
||
| function isReservedIPv6(g: number[]): boolean { | ||
| const leadingZero = g.slice(0, 5).every((x) => x === 0); |
There was a problem hiding this comment.
isReservedIPv6 misses IPv4-compatible (::a.b.c.d) and 6to4 (2002::/16) addresses.
Both forms carry an IPv4 address that the guard never judges, so private and loopback targets pass when written that way. Verified against the shipped function:
| Input | Wraps | isPublicHttpUrl |
|---|---|---|
http://[::127.0.0.1]/ |
127.0.0.1 | true |
http://[::a00:1]/ |
10.0.0.1 | true |
http://[2002:7f00:1::]/ |
127.0.0.1 | true |
http://[2002:a9fe:a9fe::]/ |
169.254.169.254 | true |
The parser normalizes [::127.0.0.1] to [::7f00:1], so the leadingZero branch on line 106 only matches :: and ::1, and g[5] is 0 rather than 0xffff. Neither special case fires and the function falls through to return false.
The new 27-entry blocked-target list covers ::ffff: and 64:ff9b:: but not ::/96 or 2002::/16.
Fix: when leadingZero && g[5] === 0 && (g[6] | g[7]) !== 0, judge groupsToIPv4(g[6], g[7]); add a g[0] === 0x2002 branch doing the same on g[1]/g[2].
| const candidate = await fetch(current, { | ||
| headers: { "User-Agent": "Shrtnr/1.0 (link preview)" }, | ||
| redirect: "manual", | ||
| signal: AbortSignal.timeout(5000), |
There was a problem hiding this comment.
The per-hop timeout raises the worst case from 5s to 30s.
The previous single fetch(url, { redirect: "follow", signal: AbortSignal.timeout(5000) }) bounded the entire followed chain at 5 seconds. This loop builds a fresh signal on each of the 6 iterations (hop = 0..MAX_REDIRECTS), so each hop gets its own budget.
A server that answers every request with a 302 to a new public URL after 4.9s holds autoLabelLink's waitUntil for roughly 30 seconds. Every link creation goes through this path (src/api/links.ts:85 and :507), and the destination is user-supplied.
Fix: hoist one AbortSignal.timeout(5000), or a deadline computed once, outside the loop and pass it to every hop.
| return slugRes.json().then(function(data) { | ||
| toast(data.error || t('client.customError'), 'error'); | ||
| }).catch(function() { toast(t('client.customError'), 'error'); }).then(function() { | ||
| window.location.href = '/_/admin/links/' + linkId; |
There was a problem hiding this comment.
The error branch navigates away, so the toast this PR adds never gets read.
toast() writes into the in-page #toast element with a 3000ms lifetime. Assigning window.location.href on the next microtask replaces the document and takes the toast with it. The user still lands on the detail page carrying only the auto-generated slug, with no durable sign that the slug they typed was rejected. That is the failure this PR set out to surface.
The three new vitest cases pass because they stub window as a plain object, so the navigation is never modelled.
doAddSlug, the handler this function was modelled on, deliberately does not navigate or close the modal on failure. Mirror that: on the error branch skip both closeModal() (line 275) and this redirect, and leave the user in the modal with the message visible.
| } | ||
|
|
||
| function rsEncode(data: number[], numEcc: number): number[] { | ||
| const exp = new Uint8Array(512); |
There was a problem hiding this comment.
rsEncode rebuilds the GF(256) tables on every call, and block splitting now multiplies that.
Each call allocates new Uint8Array(512) plus new Uint8Array(256), runs 255 + 257 iterations to fill them, then builds the generator polynomial in O(numEcc²). interleaveBlocks now calls this once per block: 2 calls for versions 6 to 9 and 4 for version 10, where the old code made exactly 1. src/api/qr.ts serves this per request.
Fix: hoist exp, log and gfMul to module scope, and memoize the generator polynomial by numEcc.
| return _build_url(self._base_url, path, query) | ||
|
|
||
| def _request(self, method: str, url: str, **kwargs: Any) -> Any: | ||
| # httpx.InvalidURL isn't a RequestError subclass; catch it too so a |
There was a problem hiding this comment.
This fix is copy-pasted into 8 method bodies when _base.py already exists for exactly this.
LinksResource._request, LinksResource._request_text, AsyncLinksResource._request, AsyncLinksResource._request_text and the four Bundles/Slugs equivalents are now byte-identical bodies carrying byte-identical three-line comments. _base.py already holds _build_url, parse_json_response and parse_text_response, the shared pieces of this same request path.
The next httpx exception-hierarchy surprise, or a retry policy, a header, a timeout, has to land in 8 places again and can silently miss one.
Fix: move _request and _request_text into a _BaseResource / _AsyncBaseResource in _base.py and have the three resource classes inherit.
| 10: 0x0a4d3, | ||
| }; | ||
|
|
||
| function readVersionInfo(grid: boolean[][]): { topRight: number; bottomLeft: number } { |
There was a problem hiding this comment.
This test asserts the writer against itself, so a placement error would still pass.
readVersionInfo recomputes a = size - 11 + (i % 3) and b = Math.floor(i / 3), byte for byte the formula makeQR uses to write the version blocks, then reads grid[b][a] and grid[a][b] exactly as the writer wrote them. Transpose the two blocks or shift them by a module and this assertion still passes. The new v7+ blocks are covered for value, not for position.
The scannability suite cannot close the gap either: jsQR derives the version from the symbol dimension and only falls back to the version bits, so it decodes fine even with wrong version information.
Fix: assert against literal module coordinates from ISO/IEC 18004 Figure 25, or compare against a known-good reference symbol.
| import { adminClientScript } from "../../client"; | ||
| import type { Translations } from "../../i18n/types"; | ||
|
|
||
| function extractTopLevelChunk(source: string, startPattern: RegExp): string { |
There was a problem hiding this comment.
extractTopLevelChunk is now copied into four test files.
The same 12-line function, down to the /^(function |var |if |window\.|document\.)/ terminator heuristic, already lives at src/__tests__/unit/client-api-error-toasts.test.ts:59 and src/__tests__/unit/client-script-escaping.test.ts:13. This PR adds copies here and in client-t-replacer.test.ts:16.
The heuristic is fragile: it breaks the moment a helper in client.ts starts with an unindented const or let, and the repair would then have to land in four places.
Fix: extract it to a shared helper module under src/__tests__/.
| assert exc_info.value.status == 0 | ||
|
|
||
|
|
||
| async def test_async_malformed_base_url_wraps_as_status_0() -> None: |
There was a problem hiding this comment.
This test never closes its client, breaking the teardown discipline the rest of the file follows.
The module's client fixture (line 33) wraps its yield in try/finally: await c.aclose() precisely so the underlying httpx.AsyncClient connection pool is released. This test builds AsyncShrtnr(...) directly on line 100 and returns after the pytest.raises block, leaving the pool open for the remainder of the session. That risks an "unclosed client" ResourceWarning or an event-loop-closed error in CI.
Fix: wrap it in async with, or add await client.aclose().
The sync twin in test_client.py is worth the same check.
| // replacement as special patterns instead of literal text. t() in | ||
| // client.ts reimplements the same interpolation client-side and needs the | ||
| // same guard, or a param value containing one of those sequences (an API | ||
| // key title, a bundle name — both free text) corrupts the rendered string. |
There was a problem hiding this comment.
Em dash in new comment text.
CLAUDE.md, Writing rules, applies to all produced material including comments: "No em dashes. Use colon, comma, or period."
This line reads // key title, a bundle name — both free text) corrupts the rendered string. A comma or a colon works here.
There was a problem hiding this comment.
makeQR treats input as Latin-1 and sizes the symbol by text.length, so non-ASCII payloads produce silently undecodable codes.
Line 13 runs data.push(text.charCodeAt(i)), which yields 233 for é (Latin-1, where UTF-8 needs 0xC3 0xA9) and 20320 for 你. For any code point above 255 the value then overflows the byte field: toBin(20320, 8) returns a 15-character string and desynchronizes the whole bit stream. The capacity check on line 17 compares against text.length rather than the encoded byte length, so the version is undersized too.
Decoding real output with jsQR:
makeQR("https://s.ex/café")returns a version-1 grid that decodes to"".makeQR("https://s.ex/你好")returns a grid jsQR cannot read at all.
Neither returns null, so callers ship a broken image. This is reachable today through the MCP qr tool (src/mcp/server.ts:491), whose base_url is caller-supplied and validated only by z.string().url(), which accepts IDN hosts.
Fix: encode with TextEncoder and derive the version from the byte length.
(Filed against the file rather than a line, because line 13 falls outside this PR's hunks.)
Weekly defect-hunting review of the app, its API, and all three SDKs. Six parallel passes (API/DB/services, admin UI, MCP/auth, TypeScript SDK, Python SDK, Dart SDK), each finding verified against the actual code and a failing-before/passing-after regression test before being fixed.
Fixes
createLink()'s custom-slug attach toasted "Link created" whether it succeeded or failed (src/client.ts)if (!slugRes.ok) { toast(t('client.linkCreated')); } else { toast(t('client.linkCreated')); }— both branches identical.attachCustomSlugAndGo(), which reads the failure body and toastsclient.customError(or the server's message), matchingdoAddSlug's existing handling of the same endpoint.createLink()against a stubbedapi()that succeeds on the link-create call and fails on the slug-attach call; asserts the failure surfaces, the generic fallback fires when the body carries no message, and the success toast still fires when the slug attaches.Client-side
t()used unsafe string-based interpolation (src/client.ts)val.replace(new RegExp(...), String(params[k]))— the plain-string form of.replace()treats$&,$`,$',$1etc. in the replacement string as special patterns instead of literal text. The server-sidecreateTranslateFn()insrc/i18n/index.tsalready guards against exactly this with a replacer function; the client-side reimplementation didn't.$-containing free text passed as a param corrupts the shown string. Two real call sites: API key titles (client.confirmDeleteKey) and bundle names (client.bundles.confirmArchive/confirmDelete), both unrestricted free text. A title likeProd $$ keyrenders asProd $ key.t()from the generated script and asserts literal interpolation, mirroring the existing server-side regression test for the same bug class.Python SDK:
httpx.InvalidURLescaped as a raw exception instead ofShrtnrError(sdk/python/src/shrtnr/resources/{links,bundles,slugs}.py)_request/_request_text(8 call sites, sync and async) caught onlyhttpx.RequestError.httpx.InvalidURL(e.g. an unparsable port) is not a subclass of it, and is raised synchronously from request-building, before any I/O.base_url(bad port, bad IDNA host, etc.) raised a rawhttpx.InvalidURLinstead of the documentedShrtnrError(status=0, ...), breaking the "network failures wrap asShrtnrError" contract inerrors.pyand the README.(httpx.RequestError, httpx.InvalidURL)at all 8 sites.base_urland assertingShrtnrError(status=0).InvalidURLisn't related toRequestError). The TypeScript SDK's fetch-based error handling and the Dart SDK'shttpclient don't have an equivalent split, so there's nothing to port.SlugRepository.disable()could zero out a link's primary flag (src/db/slug-repository.ts)is_custom = 0) slug for its whole lifetime.disable()'s primary-fallback query promotes byis_custom = 0, so disabling that one row has the query re-select the very row being demoted next, leaving the link with no primary slug at all.disableSluginlink-management.ts) already refuses to disable a non-custom slug before reaching the repository — but a landmine for any future caller (MCP tool, script, admin action) that calls the repository directly.SlugRepository.remove()already has, so the repository enforces its own invariant rather than relying solely on the one caller.SlugRepository.disable()directly on a link's only slug (bypassing the service-layer guard) and asserts it refuses, leaving the primary flag untouched.QR encoder produced unscannable codes for versions 6-10 (
src/qr.ts)?utm_medium=qrgets there) rendered a symbol no reader could decode. Versions 1-5 were unaffected.fetchPageTitlefetched any user-supplied URL server-side with no restriction (src/title-fetch.ts)autoLabelLink()fetched the link URL withredirect: "follow"and no host check, an SSRF pattern. A link at a loopback, RFC 1918, link-local or cloud-metadata address (or a public page redirecting to one) had the Worker fetch it and store the response's<title>where the link's owner can read it.isPublicHttpUrl()admits only http(s) URLs whose host is a public name or public IP literal, rejecting localhost/.localhost/.internal/.localnames and every reserved IPv4 and IPv6 range (IPv4-mapped and NAT64 literals judged by the embedded IPv4 address; alternate IPv4 spellings are normalized by the URL parser first).fetchPageTitle()fetches withredirect: "manual"and follows up to five hops itself, vetting eachLocationbefore requesting it. Hostnames are not resolved (Workers expose no DNS), so a public name pointing at a private address is left to the platform.fetchis never called; redirect tests cover public-to-public, relativeLocation, redirect-to-private, redirect-to-file:, redirect loops and a missingLocation.Verification
yarn test: 1360 tests, all green.yarn e2e: 15 failures reproduce identically (same test names, same count) on a cleanmaincheckout in this sandbox —net::ERR_CONNECTION_RESET/browser-launch issues from the sandbox's network policy, unrelated to this diff. Verified via a side-by-side worktree run againstorigin/main.ruff check/ruff format --check/mypyclean on changed files.🤖 Generated with Claude Code
https://claude.ai/code/session_017a43SYLJdvdDhMcgJF4zXM