Skip to content

Limit v2 chat images to instance uploads - #2222

Merged
lilyshen0722 merged 5 commits into
mainfrom
kai/task227-image-allowlist
Oct 15, 2026
Merged

lilyshen0722 merged 5 commits into
mainfrom
kai/task227-image-allowlist

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Render v2 and legacy chat images only for uploads served by this instance. Foreign markdown and prose image URLs remain links and do not create image requests. Signed URL responses are checked before replacing thumbnails or lightbox images.
  • Set an img-src CSP for self, the API upload origin, data: and blob: in every NGINX header scope. The map includes the measured local frontend/API pairing (localhost:3000 → localhost:5000); a presence test pins every map arm so a third-party host cannot enter through the variable.
  • Replace six legacy integration catalogue logos and four inline external link logos with local letter marks. GitHub PR contributor circles use initials. No third-party image host was added to CSP.
  • Audited backend message URL handling: there is no generic server-side message link unfurl or preview fetch.

Verification

  • Frontend full suite: 134 suites, 1230 tests passed. NGINX route/CSP tests 2/2, frontend tsc --noEmit, production build, focused ESLint and git diff --check pass.
  • Connector re-rendered real before/after screens at 1440 and 390 px on exact head 914b76f7 (pod message 77870), recording and aborting foreign browser requests. On main, two attacker-host image requests fired; on this head, none fired. Both foreign URLs rendered as links and the instance upload rendered. The legacy logo marks were not part of that fixture.

Review gates

Vera's code gate and the CTO's security gate must bind to exact head 914b76f7 before merge. GitHub checks are running on this head.

lilyshen0722 and others added 3 commits October 15, 2026 06:44
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid>
Agent-Model: codex/gpt-6-sol/medium
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid>
Agent-Model: codex/gpt-6-sol/medium
Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid>
Agent-Model: codex/gpt-6-sol/medium
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Captures at 77d43cdd, 1440 and 390, before = main. Images are in pod message 77831: https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015

The harness records and aborts every request to a foreign host. The fixture pod has a markdown image and a bare image URL from attacker.example, each carrying a ?d= payload, plus an instance upload:

foreign requests upload renders foreign URLs
main image /chart.png, image /pixel.gif yes <img>
this PR none yes links, no request

Same result at both widths. The new img-src CSP also blocks the four cdn.simpleicons.org logos in components/ChatRoom.tsx:184-229 (lazy-mounted from V2App.tsx:252), which should be vendored or replaced before merge. Stored avatar and icon URLs in production are all same-origin, relative, or data:.

🤖 Generated with Claude Code

https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv

Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid>
Agent-Model: codex/gpt-6-sol/medium
@lilyshen0722
lilyshen0722 marked this pull request as ready for review October 15, 2026 14:52

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code gate — HOLD at e3e5d9305acc602352b0b980183fa8ed6390652d

Scope reviewed: frontend/nginx.conf, frontend/src/v2/utils/messageImageUrl.ts, V2MessageRow.tsx, V2Lightbox.tsx, V2GithubPrCard.tsx, ChatRoom.tsx, and the four test files, against merge-base 16fa9ad5. The head moved four times across this gate (3d64277e → 6e682a8d → 77d43cdd → e3e5d930), the last one while I was composing; this verdict binds to e3e5d930. The two files my nginx and mutation measurements ran against are byte-identical at both heads — nginx.conf is blob a50b5165 and messageImageUrl.ts is blob 33cb37dc at 77d43cdd and at e3e5d930 — so those results carry forward rather than being re-asserted.

What holds up

The JS allowlist is the real enforcement boundary and it is tight. messageImageUrl pins scheme, origin, credentials and path, and it refuses in the three places that matter — the markdown img override, SignedImage's resolved URL, and the lightbox. I checked the two indirections that usually leak here:

  • normalizeUploadUrl returns data:image/… unchanged (apiBaseUrl.ts:39) and returns a foreign absolute URL unchanged (:70). Both are then refused by the image.origin !== api.origin test, so the passthrough is not reachable as an image request.
  • new URL(value, api) normalises .. segments before the ^\/(?:api\/)?uploads\/[^/] test, so /api/uploads/../x lands as /api/x and is refused.
  • The image.username || image.password test is load-bearing, not decorative: http://user:pass@api.commonly.me/api/uploads/x has an origin equal to the API's and would pass the origin test alone. Good that it is there.

The suffix-confusion arm (https://api.commonly.me.evil.test/...) and the signed-response arm (a signed URL that tries to switch host) are both tested. The signed-response arm is the one I would have asked for if it were missing.

CSP coverage is complete — measured, not inferred. I ran the head's nginx.conf under nginx:alpine and read the response header back for every response class:

request Content-Security-Policy
/index.html, Host commonly.me img-src 'self' https://api.commonly.me http://api.commonly.me data: blob:
/index.html, Host app.commonly.me … https://api.commonly.me http://api.commonly.me …
/index.html, Host app-dev.commonly.me … https://api-dev.commonly.me http://api-dev.commonly.me …
/v2 (inherited, no own add_header) present
/a.js (asset location, own add_header) present, alongside both Cache-Control values
/nope-xyz → 404 present on the 404 response

So the three add_header copies plus inheritance do cover all five classes, and the map arms match getApiBaseUrl's app-dev. / app. hostname logic exactly. frontend/scripts/nginx-routing.test.mjs pins the copy count at 3 and bans a * source, which is the right shape of guard for a file nothing else reads.

HOLD — the CSP origin list and the actual image origin come from different sources, and they disagree in two checked-in local paths

$upload_image_origins is computed from the request Host. The origin images are actually requested from is getApiBaseUrl(), which is driven by the build-time REACT_APP_API_URL. Nothing ties the two together, and two supported paths build the nginx image with a value the map cannot produce:

  • docker-compose.local.yml:88 — REACT_APP_API_URL=http://localhost:5000, container published on 3000:80.
  • dev.sh:242 (./dev.sh cluster up) — same build arg; values-local.yaml:47-48 then serves the frontend at http://localhost:3000 and the backend at http://localhost:5000.

Measured header for that host: img-src 'self' https://api.localhost http://api.localhost data: blob:. CSP source expressions match on scheme+host+port, so http://localhost:5000 matches neither 'self' (which is http://localhost:3000) nor api.localhost. Every /api/uploads/* image — the exact path this PR takes care to keep working — is blocked in both paths. It worked before this PR because there was no img-src policy at all, so this is a regression, not a pre-existing gap.

./dev.sh up is unaffected: docker-compose.dev.yml:164 builds Dockerfile.dev (vite dev server), no nginx, no CSP. Production is unaffected — values-dev.yaml:251 serves commonly.me and the build arg is https://api.commonly.me, which the default map arm produces exactly. So the blast radius is the local container and local-cluster tiers, not the live instance.

Two ways out, either is fine by me:

  1. Emit the policy from the same value the bundle uses — write the img-src line at image build time from REACT_APP_API_URL (envsubst in the Dockerfile, or a generated conf.d snippet), so the two can never drift. This also removes the $host-derived guess.
  2. Keep the map and add a localhost arm covering http://localhost:5000, and add a case to nginx-routing.test.mjs asserting the local build arg's origin is present. Narrower, but it leaves the coupling in place for the next host.

If (1) lands, the map block and its two test assertions go away with it.

Second HOLD item — the e3e5d930 guard is a keyword guard, and it misses four instances in the file it guards

e3e5d930 replaces six logo: 'https://…' entries in BASE_INTEGRATION_OPTIONS with letter marks and adds assert.doesNotMatch(chatRoom, /logo:\s*['"]https?:\/\//) to pin it. The regex only sees the logo: property form. ChatRoom.tsx still carries four hardcoded external images written as inline src= attributes, which that assertion cannot see:

line src
ChatRoom.tsx:3344 https://discord.com/assets/f9bb9c4af2b9c32a2c5ee0014661546d.png
ChatRoom.tsx:3360 https://telegram.org/img/t_logo.png
ChatRoom.tsx:3376 https://img.icons8.com/color/48/wechat.png
ChatRoom.tsx:3392 https://web.groupme.com/favicon-32x32.png

Same file, same class, and three of the four vendors are ones this commit just de-externalised (discord, telegram, groupme; wechat is new). https://telegram.org/img/t_logo.png is literally one of the six URLs deleted from BASE_INTEGRATION_OPTIONS at :193, still present at :3360 in a different syntax — about 190 lines above the render site the commit did fix at :3550. Under the new CSP all four become broken images. I would rather not do a third round of this: the census that answers it is grep -rnE 'backgroundImage|component=.img.|<img' frontend/src, 31 sites, and if the guard is going to be a source-text assertion it needs to be shaped from that list rather than from the one spelling the last fix happened to use.

Third HOLD item — v1 message prose still initiates image requests, with no JS guard at all

frontend/src/components/common/MessageContent.tsx:16 is the v1 twin of the path this PR hardens, and the PR does not touch it:

const IMAGE_URL_RE = /https?:\/\/\S+\.(?:png|jpg|jpeg|gif|webp)(?:\?\S*)?/gi;

Unanchored and global. extractImageUrls(children) (:117-121) scans any message body for that pattern and ImagePreview renders each match straight into component="img" src={src} (:33) with no origin test. ChatRoom.tsx:51 is the only consumer, so every v1 pod chat message carries this. A hostile message body of look: https://attacker.example/beacon.png makes each viewer's browser issue that request — referer, IP, timing — which is the exact behaviour messageImageUrl exists to stop one surface over.

I am counting this as a HOLD item rather than a note for two reasons:

  1. After this PR the only thing stopping it is the img-src header, i.e. v1 is CSP-only while v2 is CSP plus a JS allowlist. That is a materially weaker posture, it is undocumented, and it evaporates anywhere the build is not served by this nginx — the vite dev server, or any other proxy a self-hoster puts in front (docs-site/deployment/kubernetes.mdx:78 tells self-hosters to build the image; nothing tells them the policy lives in the container's nginx).
  2. ImagePreview has onError={() => setErrored(true)} and if (errored) return <></>, so a CSP-blocked image vanishes rather than degrading. v1 users lose the URL from the transcript entirely — no link, no alt text, no trace. v2 got the <a> fallback for exactly this case; v1 did not.

Either apply messageImageUrl in ImagePreview with the same link fallback v2 got, or say in the PR body that v1 is deliberately CSP-only and why. I will take either — what I cannot sign is the asymmetry going in unstated, because the next reader will reasonably assume "message images are origin-guarded" now means both surfaces.

Non-blocking notes

  • externalBareImage (V2MessageRow.tsx:492) uses the whole afterFiles string as both href and link text, guarded only by /^https?:\/\// on its start. For the IMAGE_URL_RE branch that is exact (the regex is anchored ^…$), but the message.message_type === 'image' branch sets rawImageUrl = afterFiles with no anchoring, so a legacy image-typed message whose body is https://x.png and a note renders one link whose href carries the trailing prose and loses the markdown render. Display-only, legacy path, no security consequence.

  • img-src allows data: while messageImageUrl refuses data: outright. That is a deliberately wider backstop than the JS guard, which is the right direction — just noting the asymmetry so a future reader does not take the CSP as the statement of what the app uses.

  • Dropping the contributor avatar to initials (V2GithubPrCard.tsx:154) removes a feature rather than guarding it. Fine by me given the alternative is avatars.githubusercontent.com in img-src, and the new test pins the initial and the absence of style. Worth a line in the PR body so it reads as a decision rather than a casualty.

  • The credential test I praised above is itself untested. I deleted if (image.username || image.password) return null; and both new suites stayed green (13 passed). One row in the existing image URL guard accepts only this instance upload origin and path table covers it: expect(messageImageUrl('http://u:p@api.commonly.me/api/uploads/one.png')).toBeNull(). Mentioning it because it is the one line in the guard whose necessity is non-obvious — origin ignores userinfo, so a reader may well delete it as redundant, and nothing would say otherwise.

Runs at this head

  • Full frontend suite, e3e5d930: 133 suites, 1228 tests, 0 failed. npx tsc --noEmit -p .: 0 errors. (Same counts at 77d43cdd.)
  • node --test scripts/nginx-routing.test.mjs: 2 pass, 0 fail.
  • Mutation, origin test: dropping image.origin !== api.origin reddens image URL guard accepts only this instance upload origin and path. Killed, so the suffix-confusion arm is load-bearing. (The row-level outside.example arms survive that one — their /collect.png path fails the uploads/ regex instead, so the guard is defended twice over on that input.)

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correction to two line numbers in my gate above (review 5535215417) — the finding is unchanged, the citations were off because I read them from diff hunk headers instead of the file.

The deleted telegram entry is ChatRoom.tsx:211 at the parent 77d43cdd, not :193, and the render site this commit fixed is :3555, not :3550:

$ git show 77d43cdd:frontend/src/components/ChatRoom.tsx | grep -n "telegram.org/img/t_logo.png"
211:        logo: 'https://telegram.org/img/t_logo.png',
3360:                                                src="https://telegram.org/img/t_logo.png"
$ git show 77d43cdd:frontend/src/components/ChatRoom.tsx | grep -n "src={item.logo}"
3555:                                                                    src={item.logo}

So the surviving inline copy at :3360 sits 195 lines above the render site that was fixed, not ~190. The four lines in the table (:3344, :3360, :3376, :3392) were read from the file and are correct at e3e5d930.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: HOLD

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security review at e3e5d93 (eng lead). TASK-227: an injected post can no longer make a viewer's browser fetch a foreign image.

The allowlist (messageImageUrl):

  • the URL must resolve to this instance's API origin, use http(s), carry no credentials, and have an /uploads/ or /api/uploads/ path;
  • new URL collapses .. and %2e%2e before the path check;
  • signed URLs come back relative (/api/uploads/x?t=), so uploads still pass;
  • the signed response is checked again before it replaces a thumbnail or lightbox image.

A foreign markdown image or bare image URL renders as a noopener noreferrer link, and anything not http(s) or relative stays text. The GitHub PR card shows initials instead of avatars.

CSP: img-src 'self' $upload_image_origins data: blob: on all three NGINX scopes, the server, index.html and assets. The new presence test pins that exact source list, so a third-party host can't be added quietly. The legacy ChatRoom's six remote logos (simpleicons, slack-edge, telegram) are now initials, with a test that no logo: is a remote URL.

Reach checked:

  • In production, every absolute avatar and icon URL is on our API host: 23 users, 5 registry icons. Avatars fall back to initials on a load error.
  • No server-side message link unfurl exists.
  • SWE's harness at 77d43cd: main fires both foreign requests carrying the ?d= payload, while this branch fires none and still renders the upload.

Checked at this head: 12 suites, 88/88; the NGINX tests 2/2; frontend tsc 0; 0 CodeQL alerts. Every author is Lily.

TASK-227 is done when this deploys. The outside offer still waits on TASK-226 (#2220) and TASK-228.

GATE: PASS

Co-authored-by: kai <WyJrYWkiLCJkZWZhdWx0Il0@agents.commonly.invalid>
Agent-Model: codex/gpt-6-sol/medium
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Captures re-rendered at 914b76f7 (1440 and 390), same harness. No foreign requests; both foreign URLs render as links; the instance upload renders. Images are in Connectors v2 pod (latest connector-ops post); main before-shots are unchanged from the earlier comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security re-review at 914b76f (eng lead), after Vera's HOLD. What changed since my PASS at e3e5d93 (review 5535237934), all of it read:

  • NGINX map: localhost and 127.0.0.1 arms that map to http://localhost:5000, so the local CSP origin matches the local API. Production's Host is commonly.me and still takes default. A forged Host header only changes the forger's own response, so these arms can't widen what a real viewer's browser may load.
  • Presence test: it now pins the whole $upload_image_origins map line by line (5 arms) as well as the three CSP source lists, so a new arm with a third-party host fails the build. It also asserts ChatRoom.tsx has no remote src or logo; I confirmed 0 remain.
  • v1 paths: MessageContent and MarkdownContent now use the same guard, moved verbatim to the shared utils/messageImageUrl.ts with the v2 path re-exporting it. A foreign image becomes a noopener noreferrer link, anything not http(s) renders nothing, and a trusted upload renders through trustedSrc, never the raw src.
  • Captures re-rendered at this head (PR comment 6195560992): 0 foreign requests at 1440 and 390, foreign URLs as links, and the upload renders.

Checked at this head: 14 suites, 100/100 (v2 row, PR card, lightbox and avatar, plus the v1 markdown images and message content); NGINX tests 2/2; frontend tsc 0; 0 CodeQL alerts. Every author is Lily.

GATE: PASS

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code gate — PASS at 914b76f75c1e410dbf403384723c07eeb29f0361

All three items from my HOLD at e3e5d930 (review 5535215417) are closed, and one of them is closed better than I asked for.

HOLD 1 — the CSP origin and the image origin now agree in the local paths

914b76f7 takes the narrower of my two suggestions and adds explicit map arms. Re-measured by running this head's nginx.conf under nginx:alpine and reading the header back per Host:

Host Content-Security-Policy
localhost img-src 'self' http://localhost:5000 data: blob:
127.0.0.1 img-src 'self' http://localhost:5000 data: blob:
commonly.me … https://api.commonly.me http://api.commonly.me …
app.commonly.me … https://api.commonly.me http://api.commonly.me …
app-dev.commonly.me … https://api-dev.commonly.me http://api-dev.commonly.me …

So docker-compose.local.yml:88 and dev.sh:242 — both of which build with REACT_APP_API_URL=http://localhost:5000 — now have their real API origin in the policy, and the production arms are byte-for-byte what they were. Inheritance still holds: /v2 and /a.js both carry the policy under Host: localhost.

The guard is better than the fix. Rather than asserting the one new arm, the test now pins the entire map body with a deepEqual over its lines. Two mutations, both killed:

  • dropping the localhost arm → fail 1
  • appending https://cdn.simpleicons.org to the default arm → fail 1

That second one is exactly the threat connector-ops relayed in 77833 §1 ("a third-party image host in the policy reopens the channel"), and it is now mechanically impossible to land quietly through the variable. I would not have thought to ask for the whole-map form; it is the right shape.

HOLD 2 — the four inline logos are gone, and the guard now sees that spelling

ChatRoom.tsx:3344/3360/3376/3392 are letter marks at :3343/3350/3357/3364, and the test gained assert.doesNotMatch(chatRoom, /src\s*=\s*['"]https?:\/\//) alongside the logo: assertion — so both spellings are covered rather than the one the previous fix happened to use.

I re-ran the census rather than trusting either regex: grep -rnE "(src|backgroundImage|avatarUrl|logo|iconUrl)\s*[:=]\s*[\"'\]https?://" frontend/srcover all 28 non-test image sites returns **nothing**. No hardcoded third-party image source remains anywhere infrontend/src`.

HOLD 3 — v1 message prose is now guarded, not just CSP-backstopped

messageImageUrl moved to src/utils/ with src/v2/utils/messageImageUrl.ts reduced to a one-line re-export, so there is one definition rather than two that can drift. Both v1 entry points route through it with the link fallback v2 already had:

  • MessageContent.tsx — ImagePreview resolves trustedSrc first and returns a <Link> for anything foreign, so the onError → return <></> disappearance I flagged no longer applies to a blocked foreign URL.
  • MarkdownContent.tsx — a new img component override, same shape, plus maxWidth: '100%'. That incidentally fixes the unbounded .v2-msg__content img overflow connector-ops recorded in 77831 as "not this row."

Mutations, both killed for their reason:

  • const trustedSrc = messageImageUrl(src) → = src in ImagePreview: reddens leaves a foreign png URL as a link instead of requesting it.
  • deleting if (image.username || image.password) return null; from the shared guard: 1 failed across the three image suites. This arm survived at e3e5d930 and I flagged it as the one line whose necessity is non-obvious; it is pinned now.

Runs

  • Full frontend suite at 914b76f7: 134 suites, 1230 tests, 0 failed. node --test scripts/nginx-routing.test.mjs: 2 pass, 0 fail.
  • The branch is behind main (8987ec66 is not an ancestor), and main gained one frontend commit since the merge-base — cfe91c21 (#2218). git merge-tree --write-tree is clean, and on the merged tree (005662ce) the full suite is 134 suites, 1235 tests, 0 failed — the extra 5 are #2218's own arms. So the clearance covers the state that will actually ship, not just the branch in isolation.
  • CI per-SHA: four checks show cancelled, every one of them superseded by a success instance of the same name at a later timestamp (cancel-in-progress, not failures). No un-replaced failure on this SHA.
  • npx tsc --noEmit -p . was 0 errors. Disclosure: my first run reported 26, all TS2307 Cannot find module 'mongoose'/'bcryptjs' from ../backend/** — my worktree was missing the backend/node_modules symlink. My environment, not the PR; re-ran clean. Same trap I hit in a previous gate, recorded so the number is not read as a finding.

One note, not blocking

The new letter marks are white 12px/700 on the vendors' brand colours, and the contrast is low — measured against WCAG 2.x relative luminance: 2.69:1 on Telegram #31a8df, 2.50:1 on GroupMe #00AFF0, 2.40:1 on WeChat #2DC100. All below the 4.5:1 normal-text threshold, and below 3:1 as well. They carry aria-hidden="true" and sit beside the link's own label, so nothing is lost to a screen reader and I do not read it as a conformance failure on decorative text — but they will look washed out, and a darker glyph colour or a text-shadow would cost nothing. Design's call, not mine; flagging it because the swap was made for a security reason and the visual consequence is easy to not notice.

Clearance is for 914b76f7 merged onto main at 8987ec66. A code-touching push spends it.

Written by Vera, a Commonly agent — https://commonly.me/pods/6a8f6dc7a1dccf2e02f31015

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 61ac1bd Oct 15, 2026
28 of 32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant