Skip to content

Commit fe6b6af

Browse files
sametbasbugclaude
andcommitted
Let the consent form actually reach the site it returns to
Two browser behaviours broke the consent button in staging, and neither unit nor end-to-end tests could see them: both drive the endpoint with fetch, and fetch enforces neither referrer policy nor CSP. First, the page's own `referrer-policy: no-referrer` made the browser send a literal `Origin: null` on the same-origin form POST, so our origin check rejected it. The page now sends `same-origin`: real Origin on the way in, still no referrer on the way out. The check also treats `null` as "no origin stated", so one header can no longer close the door again. Second, `form-action 'self'` covered the POST but not the 302 that follows it, and Chrome applies form-action to the whole redirect chain. The grant was being stored and the return silently blocked — nothing happened on screen, no error anywhere. The directive now carries the client's redirect origin, taken from the URI that was already matched against the registered allowlist, and read through `new URL().origin` so only scheme+host+port can reach the header. Both lessons are locked by assertions that fail when reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent c004672 commit fe6b6af

4 files changed

Lines changed: 86 additions & 15 deletions

File tree

‎scripts/orbit-site-authorization-tests.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -940,14 +940,27 @@ describe('Orbit as a sign-in door for other sites', { concurrency: false }, () =
940940
accountHandle: 'samet',
941941
ticket: 'orb_site_req_v1.payload.signature',
942942
csrfToken: 'csrf-value',
943-
cancelUrl: 'https://anime.sametbasbug.dev/auth/v1/callback?error=access_denied',
943+
redirectUri: 'https://rslltclzdfzwaxqtozmq.supabase.co/auth/v1/callback',
944944
});
945945
const html = await response.text();
946946

947947
assert.equal(response.headers.get('content-type'), 'text/html; charset=utf-8');
948948
assert.equal(response.headers.get('cache-control'), 'no-store');
949+
/* `no-referrer` OLMAMALI: o politika, bu sayfadan çıkan form gönderiminin
950+
* Origin başlığını literal `null` yapıyor ve köken kontrolü onu reddedince
951+
* "İzin ver" düğmesi çalışmıyor. Staging'de iki kez kırıldı; bu satır o
952+
* dersin kilidi. */
953+
assert.equal(response.headers.get('referrer-policy'), 'same-origin');
949954
/* Sayfada script yok ve CSP bunu kalıcı kılıyor; iframe'e alınamıyor. */
950-
assert.match(response.headers.get('content-security-policy') ?? '', /frame-ancestors 'none'/u);
955+
const csp = response.headers.get('content-security-policy') ?? '';
956+
assert.match(csp, /frame-ancestors 'none'/u);
957+
/* `form-action` dönüş kökenini içermeli. Yalnız `'self'` yazıldığında Chrome
958+
* onay POST'unun ardından gelen 302'yi engelliyor: izin kaydediliyor ama
959+
* tarayıcı siteye dönmüyor ve ekranda hiçbir şey olmuyor. Staging'de
960+
* "bastım, sayfa kaybolmadı" diye görüldü; bu satır o dersin kilidi. */
961+
assert.match(csp, /form-action 'self' https:\/\/rslltclzdfzwaxqtozmq\.supabase\.co;/u);
962+
/* Başlığa yalnız köken giriyor — yol, sorgu ya da ayırıcı taşınmıyor. */
963+
assert.equal(csp.includes('/auth/v1/callback'), false);
951964
assert.equal(/<script/iu.test(html), false);
952965

953966
assert.match(html, /E-posta adresin/u);
@@ -971,7 +984,7 @@ describe('Orbit as a sign-in door for other sites', { concurrency: false }, () =
971984
accountHandle: 'samet',
972985
ticket: 'orb_site_req_v1.a.b',
973986
csrfToken: 'csrf',
974-
cancelUrl: 'https://anime.example/cb',
987+
redirectUri: 'https://anime.example/cb',
975988
});
976989
const html = await response.text();
977990
/* İstemci adı operatör eliyle giriliyor, yani bugün güvenilir. Kaçış yine

‎scripts/orbit-site-signin-e2e-tests.ts‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -500,11 +500,27 @@ describe('Signing in to another Equinox site with Orbit', { concurrency: false }
500500
const crossSite = await consent(ticket, 'allow', { secFetchSite: 'cross-site' });
501501
assert.equal(crossSite.status, 403);
502502

503-
/* Ve gerçek hâl: aynı kökenden gelen form, `Origin` başlığı olmadan. Bu
504-
* satır staging'de kırılan yolun kendisi. */
505-
const real = await consent(ticket, 'allow', { secFetchSite: 'same-origin' });
506-
assert.equal(real.status, 302, 'the browser sends no Origin on a same-origin form post');
507-
assert.ok(locationOf(real).searchParams.get('code'));
503+
/* Ve gerçek hâl. Bu satır staging'de İKİ KEZ kırılan yolun kendisi:
504+
* ilkinde başlık yok sanmıştım, gerçekte tarayıcı literal `null`
505+
* gönderiyordu — çünkü sayfamızın referrer politikası `no-referrer`'dı ve
506+
* o politika Origin'i `null`a çeviriyor. */
507+
const nullOrigin = await consent(ticket, 'allow', {
508+
origin: 'null',
509+
secFetchSite: 'same-origin',
510+
});
511+
assert.equal(
512+
nullOrigin.status,
513+
302,
514+
'a no-referrer page makes the browser send Origin: null, which is not an attack signal',
515+
);
516+
assert.ok(locationOf(nullOrigin).searchParams.get('code'));
517+
518+
/* Başlığın tümden yokluğu da geçiyor (eski tarayıcılar). Aynı bilet
519+
* yeniden kullanılıyor: tek kullanımlık olan kod, bilet değil — bilet
520+
* süresi içinde yeni bir kod üretiyor ve izin kaydı zaten UPSERT. */
521+
const absent = await consent(ticket, 'allow', { secFetchSite: 'same-origin' });
522+
assert.equal(absent.status, 302);
523+
assert.ok(locationOf(absent).searchParams.get('code'));
508524
});
509525

510526
test('a code cannot be exchanged twice, and the replay burns the tokens', async () => {

‎src/server/http/api.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4025,7 +4025,18 @@ function siteIssuer(env: OrbitBindings): string {
40254025
* açık olurdu, öyle değil. */
40264026
function requireBrowserFormOrigin(request: Request, env: OrbitBindings): void {
40274027
const origin = request.headers.get('origin');
4028-
if (origin !== null && origin !== env.ORBIT_ALLOWED_ORIGIN) {
4028+
/* `null` başlığın yokluğu değil, "kökeni söylemeyeceğim" demek — ve
4029+
* tarayıcı bunu bizim kendi başlığımız yüzünden yapıyordu: sayfa
4030+
* `referrer-policy: no-referrer` gönderdiğinde form gönderiminin Origin'i
4031+
* literal `null` oluyor. İlk düzeltmem yalnız başlığın YOKLUĞUNU karşıladı
4032+
* ve staging'de aynı hata ikinci kez geldi.
4033+
*
4034+
* Sayfanın politikası artık `same-origin` (bkz. site-consent-page.ts), yani
4035+
* gerçek Origin geliyor. Buradaki `null` toleransı yine kalıyor: tek bir
4036+
* başlık değişikliğinin girişi yeniden kırmasına gerek yok, ve `null` bir
4037+
* saldırı sinyali değil — çapraz kökenden gelen gerçek bir form gönderimi
4038+
* kendi kökenini taşıyor. */
4039+
if (origin !== null && origin !== 'null' && origin !== env.ORBIT_ALLOWED_ORIGIN) {
40294040
throw new ApiError(403, 'origin_forbidden', 'Request origin is not allowed.');
40304041
}
40314042
const site = request.headers.get('sec-fetch-site');
@@ -4456,7 +4467,7 @@ async function handleSiteAuthorize(
44564467
accountHandle: auth.account.handle,
44574468
ticket,
44584469
csrfToken: auth.csrfToken ?? '',
4459-
cancelUrl: redirectUri,
4470+
redirectUri,
44604471
});
44614472
return attachCookies(page, [clearHostCookie(SITE_RETURN_COOKIE, true)]);
44624473
}

‎src/server/http/site-consent-page.ts‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ export function siteAuthorizationErrorPage(
159159
headers: {
160160
'content-type': 'text/html; charset=utf-8',
161161
'cache-control': 'no-store',
162+
/* Hata sayfasında form yok, o yüzden burada `no-referrer` kalabiliyor:
163+
* Origin sorunu yalnız form gönderen sayfayı ilgilendiriyor. */
162164
'referrer-policy': 'no-referrer',
163165
'x-content-type-options': 'nosniff',
164166
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
@@ -173,12 +175,19 @@ export interface SiteConsentPageInput {
173175
accountHandle: string;
174176
ticket: string;
175177
csrfToken: string;
176-
/* Kullanıcı vazgeçerse siteye bu adresle dönüyor. */
177-
cancelUrl: string;
178+
/* Onaydan (ve vazgeçmeden) sonra tarayıcının gideceği site adresi.
179+
*
180+
* Sayfada görünmüyor; CSP'nin `form-action` listesine bu adresin kökenini
181+
* eklemek için duruyor. Gerekçe aşağıda, başlığın yanında. */
182+
redirectUri: string;
178183
}
179184

180185
export function siteConsentPage(input: SiteConsentPageInput): Response {
181186
const label = escapeHtml(input.clientLabel);
187+
/* `new URL(...).origin` bir temizleyici olarak da duruyor: CSP başlığına
188+
* ancak şema+host+port giriyor, yani kayıtlı adres bir gün beklenmedik bir
189+
* şey içerse bile başlığa boşluk ya da noktalı virgül taşıyamıyor. */
190+
const redirectOrigin = new URL(input.redirectUri).origin;
182191
const items = input.scopes
183192
.map((scope) => ({ scope, text: SCOPE_TEXTS[scope] }))
184193
.filter((entry) => entry.text.title !== null)
@@ -246,12 +255,34 @@ geri alabilirsin.</p>
246255
* CSRF değeri var, ve geri tuşuyla dönülüp yeniden gönderilmesi
247256
* beklenmiyor. */
248257
'cache-control': 'no-store',
249-
'referrer-policy': 'no-referrer',
258+
/* `no-referrer` DEĞİL, ve bu fark staging'de öğrenildi.
259+
*
260+
* Referrer politikası `no-referrer` olduğunda tarayıcı, bu sayfadan
261+
* çıkan form gönderiminin `Origin` başlığını literal `null` yapıyor —
262+
* yani kendi politikamız, kendi köken kontrolümüzün önünü kesiyordu ve
263+
* "İzin ver" düğmesi `origin_forbidden` alıyordu.
264+
*
265+
* `same-origin` ikisini birden veriyor: aynı kökene giden istekte gerçek
266+
* Origin gönderiliyor, çapraz kökene çıkarken referrer yine hiç
267+
* sızmıyor — yani siteye dönerken adresimiz ve içindeki kod başka bir
268+
* yere taşınmıyor. */
269+
'referrer-policy': 'same-origin',
250270
'x-content-type-options': 'nosniff',
251271
/* Sayfada hiç script yok ve olmasına gerek yok; CSP bunu kalıcı kılıyor.
252272
* `frame-ancestors 'none'`: onay ekranı bir iframe'in içine alınıp
253-
* kullanıcıya başka bir şey gibi gösterilemez. */
254-
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
273+
* kullanıcıya başka bir şey gibi gösterilemez.
274+
*
275+
* `form-action` neden `'self'` ile yetinmiyor — bu da staging'de öğrenildi.
276+
* Form kendi ucumuza (`/v1/oauth/consent`) gidiyor, yani ilk adım `'self'`.
277+
* Ama o uç 302 ile siteye dönüyor ve Chrome `form-action`'ı yalnız ilk
278+
* adrese değil, yönlendirme zincirinin tamamına uyguluyor. `'self'` tek
279+
* başınayken sonuç şuydu: istek gidiyor, izin kaydediliyor, dönüş sessizce
280+
* engelleniyor — ekranda hiçbir şey olmuyor, hata da yok.
281+
*
282+
* Kökeni sabit yazmıyorum: hangi siteye dönüleceğini istemci kaydı
283+
* söylüyor. Bu adres buraya gelmeden önce kayıtlı adres listesiyle
284+
* birebir eşleştirildi, yani genişletme değil, o eşleşmenin tekrarı. */
285+
'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${redirectOrigin}; frame-ancestors 'none'; base-uri 'none'`,
255286
},
256287
});
257288
}

0 commit comments

Comments
 (0)