This starts with a correction of something I told you twice, in #43 and in the thread on my fork. I was wrong, and I would rather retract it in public than let it quietly stand.
What I claimed, and why it is wrong
I said uploadToLibrary was open to CSRF: it calls xhr.send(formData) with no custom header, so the request is multipart/form-data, a CORS-simple content type, so no preflight, so a page anywhere could fire it.
Every step of that is true except the conclusion. It assumes CORS is advisory — that the browser is told not to read the response while the request still reaches the handler. Vert.x's CorsHandler does not work that way. It rejects a disallowed origin server-side, on actual requests and not only preflights, with a 403 before any handler runs.
I verified this against your exact configuration rather than reasoning about it again — a bare Vert.x 3.9 server with CorsHandler.create("http://localhost:.*") and allowedMethods(GET, POST), nothing else:
Origin: (none) -> 200 reached the handler
Origin: http://localhost:8080 -> 200
Origin: http://localhost:31337 -> 200
Origin: http://127.0.0.1:8080 -> 403
Origin: https://evil.example -> 403
Origin: http://localhost.evil.example -> 403
Origin: http://evil.example:8080 -> 403
A browser always sends Origin on a cross-origin POST, so an attacker page gets a 403. The CSRF I described is not exploitable. Please do not spend time on it on my account.
The requests with no Origin reaching the handler are not a hole either: that is curl and scripts, which are not browser-mediated and already have whatever access the user has.
What the same table does show
Two things, and I am genuinely unsure whether either is worth your time.
1. Any localhost port is trusted. http://localhost:31337 is allowed, because http://localhost:.* matches every port. So any web origin on the machine — another project's dev server, a local tool with an XSS, anything the user has open on a localhost origin — can drive STUdio's API: list the library, download packs, upload, delete, operate the device. Nothing stands in the way, since there is no authentication.
How much that matters depends on your threat model. The counter-argument is that anything running locally already has the user's files. The counter to that is that a web page on a localhost origin is not local code execution: it is reached by visiting a URL, and it inherits the ability to drive an unauthenticated API that the loopback binding exists to protect. On an application deliberately restricted to the loopback, "what else is on this machine" is the surface that is left.
2. http://127.0.0.1:8080 is refused — the address the server itself binds. This one is a plain bug rather than a judgement call. A user who opens the UI at http://127.0.0.1:8080 instead of http://localhost:8080 gets a browser that sends Origin: http://127.0.0.1:8080 on every POST, so every state-changing call — upload, convert, remove, and the device operations — comes back 403. Reads mostly work, since browsers omit Origin on same-origin GETs. The UI half-works, in a way that is hard to diagnose from the outside.
This predates #43 and is not caused by it.
What a fix would look like
Both come from the same place: the allowed origin is written as a pattern over localhost rather than derived from what the server actually serves. The origin is known — it is listenHost() and LISTEN_PORT. Allowing exactly http://localhost:<port> and http://127.0.0.1:<port>, and nothing else, closes the port wildcard and fixes the IP spelling in one change.
One consequence worth knowing before you decide. Tightening it breaks the yarn start event bus. Under the dev server the page is served from :3000; proxy forwards /api and /locales server-side so those stay same-origin, but the SockJS connection goes straight to :8080 and is genuinely cross-origin. It needs http://localhost:3000 allowed. Gating that on the env=dev flag you already have would keep the production pattern tight, at the cost of a branch in the CORS setup.
A smaller point for completeness: x-requested-with is already in allowedHeaders, but nothing sends or requires it. Requiring it on state-changing routes would force a preflight and give a second, independent barrier. I mention it because it is already half-present, not because I think it is needed once the origin is exact.
The actual question
Is this worth addressing, or is it fine as it is?
I can see the argument for leaving it. Item 1 needs an attacker who already has a foothold in the user's browser on a local origin. Item 2 has an obvious workaround — use localhost. Against that, item 2 is a real user-visible failure with a confusing shape, and the fix for both is small and testable.
If you want it done, I am happy to write it, with the dev-mode branch and a test over the origin table above. If you would rather not, close this — after the correction at the top I would rather ask than assume I have found something.
This starts with a correction of something I told you twice, in #43 and in the thread on my fork. I was wrong, and I would rather retract it in public than let it quietly stand.
What I claimed, and why it is wrong
I said
uploadToLibrarywas open to CSRF: it callsxhr.send(formData)with no custom header, so the request ismultipart/form-data, a CORS-simple content type, so no preflight, so a page anywhere could fire it.Every step of that is true except the conclusion. It assumes CORS is advisory — that the browser is told not to read the response while the request still reaches the handler. Vert.x's
CorsHandlerdoes not work that way. It rejects a disallowed origin server-side, on actual requests and not only preflights, with a 403 before any handler runs.I verified this against your exact configuration rather than reasoning about it again — a bare Vert.x 3.9 server with
CorsHandler.create("http://localhost:.*")andallowedMethods(GET, POST), nothing else:A browser always sends
Originon a cross-origin POST, so an attacker page gets a 403. The CSRF I described is not exploitable. Please do not spend time on it on my account.The requests with no
Originreaching the handler are not a hole either: that iscurland scripts, which are not browser-mediated and already have whatever access the user has.What the same table does show
Two things, and I am genuinely unsure whether either is worth your time.
1. Any localhost port is trusted.
http://localhost:31337is allowed, becausehttp://localhost:.*matches every port. So any web origin on the machine — another project's dev server, a local tool with an XSS, anything the user has open on alocalhostorigin — can drive STUdio's API: list the library, download packs, upload, delete, operate the device. Nothing stands in the way, since there is no authentication.How much that matters depends on your threat model. The counter-argument is that anything running locally already has the user's files. The counter to that is that a web page on a localhost origin is not local code execution: it is reached by visiting a URL, and it inherits the ability to drive an unauthenticated API that the loopback binding exists to protect. On an application deliberately restricted to the loopback, "what else is on this machine" is the surface that is left.
2.
http://127.0.0.1:8080is refused — the address the server itself binds. This one is a plain bug rather than a judgement call. A user who opens the UI athttp://127.0.0.1:8080instead ofhttp://localhost:8080gets a browser that sendsOrigin: http://127.0.0.1:8080on every POST, so every state-changing call — upload, convert, remove, and the device operations — comes back 403. Reads mostly work, since browsers omitOriginon same-origin GETs. The UI half-works, in a way that is hard to diagnose from the outside.This predates #43 and is not caused by it.
What a fix would look like
Both come from the same place: the allowed origin is written as a pattern over
localhostrather than derived from what the server actually serves. The origin is known — it islistenHost()andLISTEN_PORT. Allowing exactlyhttp://localhost:<port>andhttp://127.0.0.1:<port>, and nothing else, closes the port wildcard and fixes the IP spelling in one change.One consequence worth knowing before you decide. Tightening it breaks the
yarn startevent bus. Under the dev server the page is served from:3000;proxyforwards/apiand/localesserver-side so those stay same-origin, but the SockJS connection goes straight to:8080and is genuinely cross-origin. It needshttp://localhost:3000allowed. Gating that on theenv=devflag you already have would keep the production pattern tight, at the cost of a branch in the CORS setup.A smaller point for completeness:
x-requested-withis already inallowedHeaders, but nothing sends or requires it. Requiring it on state-changing routes would force a preflight and give a second, independent barrier. I mention it because it is already half-present, not because I think it is needed once the origin is exact.The actual question
Is this worth addressing, or is it fine as it is?
I can see the argument for leaving it. Item 1 needs an attacker who already has a foothold in the user's browser on a local origin. Item 2 has an obvious workaround — use
localhost. Against that, item 2 is a real user-visible failure with a confusing shape, and the fix for both is small and testable.If you want it done, I am happy to write it, with the dev-mode branch and a test over the origin table above. If you would rather not, close this — after the correction at the top I would rather ask than assume I have found something.