nandi/frqpublic Fork 0
4ef6dc8
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

An upload that arrives and cannot be read back

The note here said the media endpoint sends no
`Access-Control-Allow-Origin`. It does send one -- for
`https://irc.freeq.at`, and for no other origin asked. It is an
allowlist, not an absence, and neither this build nor the deployed one
is on it.

Which changes what happens rather than only why. With no
`Authorization` header the POST is a simple request, so nothing is
preflighted and nothing is stopped: the picture uploads, and the
answer naming it is withheld. A URL nobody can read is a picture
nobody can see.

`credentials: "include"` goes, having been the one part of this we
chose. A request that sends no credentials can be allowed by a plain
`*`; one that sends them obliges the server to name this origin
specifically. The smaller ask is the one more likely to be granted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-20T12:31:19-07:00 Browse files
4ef6dc8 parent: 8c5b241
modified flutter/web/frq_host.js +21 -5
@@ -90,10 +90,26 @@
9090 // freeq's media endpoint as the multipart form it wants. The URL that
9191 // comes back goes in the line — that is how a picture travels on IRC.
9292 //
93- // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked
94- // by the browser from any origin but freeq's own. It is written the way it
95- // will work rather than left out: the same POST from the desktop build has
96- // no such limit, and one header on the server end is all this waits for.
93+ // From a browser this is blocked, and the shape of the block is worth
94+ // writing down because it is not the one it looks like. freeq does send
95+ // CORS headers -- `vary: origin`, an allow-methods and an allow-headers --
96+ // and answers with `access-control-allow-origin: https://irc.freeq.at` for
97+ // exactly one origin: its own. It is an allowlist, and we are not on it,
98+ // for this build or the deployed one. Being added is somebody else's
99+ // decision, the same one as the broker's `return_to` list.
100+ //
101+ // Sending no `Authorization` header is what makes it a *simple* request,
102+ // which means no preflight -- so the POST is not stopped, only the answer
103+ // is. The picture does upload; the URL naming it is withheld, and a URL
104+ // nobody can read is a picture nobody can see. The endpoint wants no auth
105+ // for a public upload (it says "No file provided", not "Unauthorized"),
106+ // and no credentials are sent, which is deliberate: a request without
107+ // them can be allowed by a plain `*`, where `credentials: "include"`
108+ // would oblige the server to name this origin specifically. The smaller
109+ // ask is the one more likely to be granted.
110+ //
111+ // Written the way it will work rather than left out -- the same POST from
112+ // the desktop build has no such limit.
97113 function pickAndUpload(want) {
98114 var input = document.createElement("input");
99115 input.type = "file";
@@ -114,7 +130,7 @@
114130 if (want.channel) form.append("channel", want.channel);
115131 form.append("file", file, file.name || "picture.png");
116132 fetch("https://" + want.host + "/api/v1/upload",
117- {method: "POST", body: form, credentials: "include"})
133+ {method: "POST", body: form})
118134 .then(function (r) { return r.text().then(function (t) {
119135 return {ok: r.ok, status: r.status, body: t}; }); })
120136 .then(function (r) {
@@ -90,10 +90,26 @@
90 // freeq's media endpoint as the multipart form it wants. The URL that90 // freeq's media endpoint as the multipart form it wants. The URL that
91 // comes back goes in the line — that is how a picture travels on IRC.91 // comes back goes in the line — that is how a picture travels on IRC.
92 //92 //
93- // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked93+ // From a browser this is blocked, and the shape of the block is worth
94- // by the browser from any origin but freeq's own. It is written the way it94+ // writing down because it is not the one it looks like. freeq does send
95- // will work rather than left out: the same POST from the desktop build has95+ // CORS headers -- `vary: origin`, an allow-methods and an allow-headers --
96- // no such limit, and one header on the server end is all this waits for.96+ // and answers with `access-control-allow-origin: https://irc.freeq.at` for
97+ // exactly one origin: its own. It is an allowlist, and we are not on it,
98+ // for this build or the deployed one. Being added is somebody else's
99+ // decision, the same one as the broker's `return_to` list.
100+ //
101+ // Sending no `Authorization` header is what makes it a *simple* request,
102+ // which means no preflight -- so the POST is not stopped, only the answer
103+ // is. The picture does upload; the URL naming it is withheld, and a URL
104+ // nobody can read is a picture nobody can see. The endpoint wants no auth
105+ // for a public upload (it says "No file provided", not "Unauthorized"),
106+ // and no credentials are sent, which is deliberate: a request without
107+ // them can be allowed by a plain `*`, where `credentials: "include"`
108+ // would oblige the server to name this origin specifically. The smaller
109+ // ask is the one more likely to be granted.
110+ //
111+ // Written the way it will work rather than left out -- the same POST from
112+ // the desktop build has no such limit.
97 function pickAndUpload(want) {113 function pickAndUpload(want) {
98 var input = document.createElement("input");114 var input = document.createElement("input");
99 input.type = "file";115 input.type = "file";
@@ -114,7 +130,7 @@
114 if (want.channel) form.append("channel", want.channel);130 if (want.channel) form.append("channel", want.channel);
115 form.append("file", file, file.name || "picture.png");131 form.append("file", file, file.name || "picture.png");
116 fetch("https://" + want.host + "/api/v1/upload",132 fetch("https://" + want.host + "/api/v1/upload",
117- {method: "POST", body: form, credentials: "include"})133+ {method: "POST", body: form})
118 .then(function (r) { return r.text().then(function (t) {134 .then(function (r) { return r.text().then(function (t) {
119 return {ok: r.ok, status: r.status, body: t}; }); })135 return {ok: r.ok, status: r.status, body: t}; }); })
120 .then(function (r) {136 .then(function (r) {