openvibers/openvibe.events Commits

Realtime streams: room for a shared address, and a refusal pages can read (CORS on nginx's 429) (#231)

OP OpenVibers committed 10 days ago · a4bc074607 · 2 files, +34 −3

nginx allowed 20 streams and 2 new a second per address; every open OpenVibe tab holds
one, and a household or a carrier's NAT shares an address. Over it, nginx answered a
bare 429 without CORS, which pages saw as "blocked by CORS policy" (11-03: a sweep of
the sites from one address hit it 16 times on Live, Help, Homes, Tours, Trade, Video).
Now 60 streams and 5 a second (burst 60) per address, and a refusal is a 429
problem+json with Access-Control-Allow-Origin and Retry-After: 10 (no credentials, no data).

Changes

ddiff --git a/deploy/nginx/openvibe.events.conf b/deploy/nginx/openvibe.events.conf
iindex 704a970..bf7aff3 100644
---- a/deploy/nginx/openvibe.events.conf
++++ b/deploy/nginx/openvibe.events.conf
@@@ -27,7 +27,10 @@
## ═══════════════════════════════════════════════════════════════
# Zone names carry this site's prefix (ovevents_apex_*) so they never collide with another vhost's in one config.
--limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=2r/s;
++# Streams: every open OpenVibe tab holds one (the shared footer's release notices, Live's live-now), and a household or
++# a carrier's NAT puts many people behind one address. 11-03: at 20 streams and 2 new a second per address, a sweep of
++# the sites from one address got 16 refusals, which reached pages as CORS errors (nginx's 429 had no CORS header).
++limit_req_zone $binary_remote_addr zone=ovevents_apex_connect:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=ovevents_apex_streams:10m;
limit_req_zone $binary_remote_addr zone=ovevents_apex_api:10m rate=20r/s;
@@@ -81,8 +84,12 @@ server {
# Cloudflare's 100 s idle limit. CORS is answered by the service (https://*.openvibe.* only).
location = /realtime/stream {
access_log /var/log/nginx/openvibe.events.access.log ov_events_noquery; # never the ?ticket= (log_format_events_noquery.conf)
-- limit_req zone=ovevents_apex_connect burst=20 nodelay;
-- limit_conn ovevents_apex_streams 20;
++ limit_req zone=ovevents_apex_connect burst=60 nodelay;
++ limit_conn ovevents_apex_streams 60;
++ # Over a limit: a 429 the page can read (CORS, Retry-After), not an opaque refusal.
++ limit_req_status 429;
++ limit_conn_status 429;
++ error_page 429 = @ovevents_stream_busy;
proxy_pass http://127.0.0.1:4300;
proxy_http_version 1.1;
proxy_set_header Connection "";
@@@ -101,6 +108,17 @@ server {
send_timeout 1h;
}
++ # The refusal above, in the shape the service answers (problem+json), readable from any page: it carries no data.
++ location @ovevents_stream_busy {
++ default_type application/problem+json;
++ add_header Access-Control-Allow-Origin $http_origin always;
++ add_header Vary Origin always;
++ add_header Retry-After 10 always;
++ add_header Cache-Control no-store always;
++ add_header X-Content-Type-Options nosniff always;
++ return 429 '{"type":"about:blank","title":"realtime.busy","status":429,"code":"realtime.busy","detail":"too many realtime streams from this address; retry shortly"}';
++ }
++
# The client address comes from $remote_addr only on every proxied location: never appended to,
# never passed through. X-Forwarded-For on every API location: the service treats a request without it from
# 127.0.0.1 as a local caller (GET /metrics answers only those).
ddiff --git a/test/home.test.js b/test/home.test.js
iindex fe8e442..7b3ba31 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -258,6 +258,19 @@ t('llms-full.txt is the same map in full text, starting with the site name', asy
assert.ok(r.body.includes('### Guide'), 'each page with its full text');
});
++t('deploy/nginx/openvibe.events.conf: realtime streams allow a shared address, and a refusal is readable (CORS)', () => {
++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
++ const stream = /location = \/realtime\/stream \{([\s\S]*?)\n \}/.exec(conf)[1];
++ assert.match(stream, /limit_conn ovevents_apex_streams 60;/);
++ assert.match(stream, /limit_req zone=ovevents_apex_connect burst=60 nodelay;/);
++ assert.match(stream, /limit_req_status 429;\s*limit_conn_status 429;\s*error_page 429 = @ovevents_stream_busy;/);
++ const busy = /location @ovevents_stream_busy \{([\s\S]*?)\n \}/.exec(conf)[1];
++ assert.match(busy, /add_header Access-Control-Allow-Origin \$http_origin always;/);
++ assert.match(busy, /add_header Retry-After 10 always;/);
++ assert.match(busy, /return 429 '\{"type":"about:blank","title":"realtime\.busy"/);
++ assert.ok(!/Access-Control-Allow-Credentials/.test(busy), 'no credentials: the answer carries no data');
++});
++
t('deploy/nginx/openvibe.events.conf is the API and product origin, with no redirect to the old host', () => {
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.events.conf'), 'utf8');
assert.match(conf, /server_name openvibe\.events;/);