fix(website): Add Access control allow origin header for website file requests - #7304
fix(website): Add Access control allow origin header for website file requests#7304tombch wants to merge 1 commit into
Access control allow origin header for website file requests#7304Conversation
|
Claude finished @tombch's task in 2m 18s —— View job ReviewThe diagnosis in the PR description is correct and the fix is the right one. Two inline comments, both non-blocking. Your security reasoning holds. The fix is complete, not just half the chain. Worth stating explicitly since it's the thing that could have made this a partial fix: the client uses Scope is right. Minor, not worth an inline comment: an accession that fails The Not verifiedI could not run
|
| // Allow requests from any origin | ||
| // Same as the backend allows on /files/get, which this route proxies | ||
| function withCorsHeader(headers: Headers): Headers { | ||
| headers.set('Access-Control-Allow-Origin', '*'); | ||
| return headers; | ||
| } |
There was a problem hiding this comment.
No OPTIONS handler is exported, so this route only supports simple CORS requests. That's sufficient today — the only cross-origin caller is DataTableEntryValue.tsx:148, fetch(url, { method: 'HEAD', redirect: 'follow' }) with no custom headers, which the browser sends without a preflight.
It becomes a silent trap the moment anyone adds a header to that fetch (e.g. Authorization, or a custom X- header): the preflight OPTIONS would hit Astro, get no matching handler, and the request would fail with the same opaque CORS error this PR is fixing. The backend already covers this via allowedMethods(... "OPTIONS") in WebConfig.kt:16.
Worth either exporting a trivial OPTIONS handler alongside GET/HEAD, or extending the comment to record that only simple requests are supported.
There was a problem hiding this comment.
I think adding a comment is sufficient for now
| test('allows any origin on the S3 redirect', async () => { | ||
| const response = await callRoute({ isLoggedIn: false }); | ||
| expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); | ||
| }); |
There was a problem hiding this comment.
withCorsHeader is applied at three call sites, but only the 307 redirect path is covered here. The untested passthrough branch ([fileName].ts:45) is the one most likely to regress, because it's the only place where the header map is seeded from the backend response (new Headers(response.headers)) rather than built fresh — a future change there could easily drop or overwrite the header.
Suggest adding at least the passthrough case, e.g. a 404 from the backend:
test('allows any origin on non-redirect responses', async () => {
fetchMock.mockResolvedValue(new Response('not found', { status: 404 }));
const response = await callRoute({ isLoggedIn: false });
expect(response.status).toBe(404);
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
});| import { getAccessToken } from '../../../../utils/getAccessToken'; | ||
|
|
||
| // Allow requests from any origin | ||
| // Same as the backend allows on /files/get, which this route proxies |
There was a problem hiding this comment.
| // Same as the backend allows on /files/get, which this route proxies |
Just an idea - (A) maybe it's not needed (B) proxied would initially make me think we're passing through the whole file, which we're not (though I accept it's accurate)
There was a problem hiding this comment.
[but we say proxy below so ignore B]
There was a problem hiding this comment.
claude actually thinks it is good to make these changes even if we were to refactor FileSizeComponent:
This is the URL the backend embeds in its public released-data API response (what getReleasedData/LAPIS consumers pull metadata
from) for every file attached to a public sequence. Any third party building on top of that public API — a genome browser, an
analysis notebook, some downstream tool — pulls that URL out of the JSON and does its own fetch() on it, from its own origin.
That's the real cross-origin scripted consumer this header is for.
There was a problem hiding this comment.
Yeah I can change the wording! In terms of the header, I think it makes sense out of the options, mainly as that's why the backend URLs already worked for the file size fetch.
There's also the option to do a relative URL in the file size component (like how FilesDialog does) but I just tried this and its a lot of prop threading of accession version and file category. There's also the option to include file size in released data but not sure we want that?
There was a problem hiding this comment.
There's also the option to include file size in released data but not sure we want that?
I feel like this would make sense (but is probably not possible as released data is consumed by SILO so it needs a specific structure)
I think we should add this header either way for third party consumers
| if (response.status === 307 || response.status === 302) { | ||
| const s3Url = response.headers.get('Location'); | ||
| if (!s3Url) { | ||
| return new Response('Backend redirect missing Location header', { status: 500 }); | ||
| return new Response('Backend redirect missing Location header', { | ||
| status: 500, | ||
| headers: withCorsHeader(new Headers()), | ||
| }); | ||
| } | ||
| return new Response(null, { | ||
| status: response.status, | ||
| // eslint-disable-next-line @typescript-eslint/naming-convention | ||
| headers: { Location: s3Url }, | ||
| headers: withCorsHeader(new Headers({ Location: s3Url })), | ||
| }); | ||
| } |
There was a problem hiding this comment.
Reading this code I don't actually see that we need this branch at all?
There was a problem hiding this comment.
[sorry I know that's not on you/ this PR]
There was a problem hiding this comment.
@theosanderson-agent do you see a reason for the 307/302 branch?
There was a problem hiding this comment.
No, I don't think it's needed anymore. It looks like a leftover from history:
- The branch was added in feat(website): Add dialog to view processed files in 'review' window (before approving) #4298, when the fallthrough was
new Response(response.body, { status })with no headers, soLocationhad to be forwarded explicitly. - feat: display sizes of extra files on sequence details page #5077 then changed the fallthrough to copy all backend headers (
new Headers(response.headers)), which already includesLocation. Since then the branch has been redundant: for a 307 the fallthrough would return the same status, the sameLocation, and now the same CORS header. - The
302check has never matched anything: the backend always returnsSC_TEMPORARY_REDIRECT(307). The 302 seems to date from the pre-feat(website): Add dialog to view processed files in 'review' window (before approving) #4298 version of this route, which itself issued a 302. - Node's
fetchwithredirect: 'manual'exposes the real 307 response (no opaque-redirect filtering like in browsers), soLocationis readable in the fallthrough. The branch already relied on that anyway.
The only behavioural differences if it's removed: the 307 would also pass through any other backend headers (e.g. Content-Length: 0, Vary), which is harmless, and the "missing Location" 500 case would go away, which would just surface as a 307 without Location instead.
I checked locally: with the whole if block deleted, [fileName].spec.ts still passes (4/4), including the 307 + Location assertions.
There was a problem hiding this comment.
Yeah I guess it would be losing a guard that the location is there, but that is something the backend explicitly sets so should always be there really
anna-parker
left a comment
There was a problem hiding this comment.
approving as I think the suggestions are small and the 302/307 branch can also be removed in a follow up PR if needbe
Summary
Switching to
outputFileUrlType: websitein #7301 showed these errors in the CI:this only appears for
chromiumbecause of logic inconsole-warnings.fixture.ts:CORS always hurts my head but through some discussions with Claude the issue is essentially:
fetchto do aHEADrequest to whatever the absolute file URL is.backend, this was fine because the backend hasallowedOrigins('*')inWebConfig.kt.website, this hits[fileName].tswhich never returned anAllow origins: *header to the browser. Therefore, any cross origin request here gets blocked by the browser.localhostbut the fetch request goes to10.1.0.60which is the GitHub Actions runner's own private network address (therefore a cross origin request).The fix is then to do what the backend does and
Allow origins: *for this website endpoint. As far as I understand, this is not an issue security wise as with the current setup,Allow origins: *means a cross-origin request with user cookies would get a response but the browser will refuse to read it. If we also enabledAccess-Control-Allow-Credentials: truethat could cause problems with a malicious site making authenticated requests.Notes
I would think a more straightforward thing in future is to have the file size not need to be requested separately, and remove the separate
HEADrequest entirely, I think this is a bigger change though.Screenshot
PR Checklist
🚀 Preview: Add
previewlabel to enable