Dokumentacja AlgoJudge0.1

Własne reverse proxy

Pięć reguł, gdy stawiasz instalację za proxy, które już prowadzisz.

COMPOSE_PROFILES=app,data. Dołączony nginx nie startuje; Server i Client są publikowane na 127.0.0.1:8080 i 127.0.0.1:8082, a Ty kierujesz na nie własne proxy.

Pięć reguł

1. Jeden origin, /api/v1/ na Server, reszta na Client

Dopiero na jednym originie działa API_BASE_URL=/, a wtedy nie ma żadnego CORS do popsucia. Client sam dokleja /api/v1 — ścieżka jest stała w każdej instalacji — więc ta zmienna nazywa origin, a nigdy ścieżkę.

Jest stała, bo pomyłka w tym miejscu jest cicha. Tam, gdzie jedna domena obsługuje obie połowy, API nie może stać w korzeniu, bo korzeniem jest aplikacja; a prefiks, który raz jest, a raz go nie ma, każde wdrożenie musiałoby odgadywać na własną rękę. Pomyl się, a Client zapyta właściwy host o niewłaściwą ścieżkę i dostanie w odpowiedzi index.html aplikacji zamiast błędu. Wartość zapisana w jednej ze starszych postaci — kończąca się na /v1 albo /api/v1 — jest normalizowana, a nie odrzucana.

Gniazdo zdarzeń to /api/v1/ws, pod tym samym prefiksem, więc nie potrzebuje własnej reguły poza nagłówkami upgrade i timeoutem odczytu dłuższym niż cicha sesja gniazda.

2. Nie kieruj ruchu na /api/v1/admin

`/api/v1/admin` nigdy nie może przechodzić przez proxy

Odpowiada wyłącznie na własnym interfejsie pętli zwrotnej Servera. Żądanie przez proxy dociera do tego kontenera jako brama mostka, więc i tak dostałoby 404 jeden przeskok dalej.

Odrzucaj je na krawędzi mimo to. Odmowa, która nie kosztuje połączenia w górę strumienia, jest lepsza niż taka, która je kosztuje, a ktoś czytający Twoją konfigurację powinien widzieć, że powierzchnia operatora celowo nie jest proxowana, zamiast zastanawiać się, czy o niej zapomniano. Dołączony nginx zwraca dla tej ścieżki 404 dokładnie z tych dwóch powodów.

Wchodzi się przez docker compose exec -T server aj-admin … i to cała wspierana droga. Opublikowane 127.0.0.1:8080 nie jest tylnymi drzwiami: połączenie do pętli zwrotnej hosta również dociera jako brama mostka.

3. Nie zamieniaj 502, 503 ani 504 od Servera na własną stronę

Client je czyta. Przerwa techniczna jest odpowiedzią 503 Servera z ciałem server.maintenance i nagłówkiem Retry-After, a Client zamienia dokładnie to we własną stronę przerwy, w brandingu i języku tej instalacji, i odpytuje /api/v1/health, żeby dowiedzieć się, że może wrócić.

Jeśli je przechwycisz, zastąpisz to czymś gorszym i ukryjesz powód, a gdy Twoja reguła jest na tyle szeroka, że łapie też Runnera — zniweczysz drenowanie, więc kopia ruszyłaby, gdy Runner jeszcze zapisuje wynik. W terminach nginxa: proxy_intercept_errors off na trasie API.

Jedyny przypadek, w którym statyczna strona pomaga, to trasa aplikacji i tylko dla 502 oraz 504: nie ma kontenera Client, więc przeglądarka dostaje błąd połączenia i nie ma już aplikacji, która pokazałaby komunikat. Nigdy 503 — tak odzywa się Server przez trasę API.

4. Ustaw TRUSTED_PROXY_NETWORKS na sieć swojego proxy

Server odmawia startu, dopóki nie wskażesz mu, czyim informacjom o adresie odwiedzającego ma wierzyć. Jeśli zaufasz każdemu nadawcy X-Forwarded-For, odwiedzający sam poda swój adres; jeśli tylko pętli zwrotnej, zapisze się proxy zamiast człowieka.

Domyślna wartość nazywa sieć Compose, w której stoi dołączony nginx. Za własnym proxy ta domyślna jest zła — wpisz tam sieć swojego proxy. Jeśli proxy nie ma w ogóle, none jest pełną odpowiedzią.

Wpisz adres sieci, nie hosta: 172.28.0.5/24 odrzucamy przy starcie, podając nazwę zmiennej i adres, który powinien się tam znaleźć. .NET normalizuje CIDR z bitami hosta bez słowa, co zamieniłoby „jedną maszynę” w „całą pracownię”.

5. Wysyłaj X-Forwarded-Proto

Bez tego Server widzi http, a jego przekierowanie na HTTPS odpowiada przekierowaniem na siebie. To nieskończona pętla i wygląda jak aplikacja, która nigdy się nie ładuje.

Co jeszcze przekazywać

Dołączony nginx wysyła Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host i X-Forwarded-Port, ustawia client_max_body_size na 128 MB, żeby pasowało do własnego limitu żądania Servera, i wyłącza buforowanie w obie strony — Client niczego nie streamuje, a pobrania Servera są już strumieniowane i bez sensu lądowałyby najpierw na dysku.

Wyłączenie buforowania nie wyłącza bufora nagłówków — i lepiej wiedzieć o tym, zanim się zdarzy. Nagłówki odpowiedzi zawsze muszą zmieścić się w proxy_buffer_size, domyślnie 4 kB, a gdy się nie mieszczą, nginx odpowiada 502 i zapisuje w logu upstream sent too big header. Do przeglądarki nie dociera nic, więc wygląda to na awarię usługi za proxy, a nie na odmowę samego proxy.

Najbliżej granicy jest odpowiedź kończąca logowanie federacyjne: kasuje .AspNetCore.Correlation.* i .AspNetCore.OpenIdConnect.Nonce.*, ustawiając przy tym .AspNetCore.Identity.Application, który sam bywa dzielony na C1 i C2, gdy claimy go wydłużą. Gorzej jest z systemem tożsamości za tym samym proxy — zmierzone 2026-09-01: odpowiedź autoryzacyjna Keycloaka z AUTH_SESSION_ID, KC_AUTH_SESSION_HASH i KC_RESTART, gdzie ten ostatni jest tokenem JWT, nie zmieściła się w 4 kB i każde logowanie kończyło się 502. Dołączony nginx to ustawia; Twoje proxy też musi.

proxy_buffer_size       16k;
proxy_buffers         4 16k;
proxy_busy_buffers_size 16k;

Wysyła Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, X-Frame-Options: SAMEORIGIN i Permissions-Policy, każdy z always, żeby nagłówek nie znikał na odpowiedziach błędu — czyli dokładnie na tych, które najłatwiej wywołać atakującemu.

X-Frame-Options to celowo SAMEORIGIN, a nie DENY — tyle że nie obejmuje tego przypadku LTI, dla którego został wybrany. SAMEORIGIN pozwala osadzić stronę tylko wtedy, gdy osadza ją ten sam origin. Przy uruchomieniu LTI osadza ją LMS, więc Moodle pod moodle.example.edu, który osadza tę instalację pod algojudge.example.edu, zostanie odrzucony dokładnie tak samo, jak odrzuciłby go DENY. Zmierzone w przeglądarce, a nie wyczytane ze specyfikacji.

Dla układu, który ten nagłówek obejmuje — LMS i ta instalacja na jednym originie, rozdzielone ścieżką — wartość jest poprawna i dlatego zostaje: domyślne rozluźnienie polityki ramek w każdej instalacji nie jest decyzją, którą stos podejmuje za Ciebie. Jeśli Twój LMS stoi pod inną nazwą, zastąp ten nagłówek, bo X-Frame-Options nie umie wskazać obcego origin, a Content-Security-Policy umie:

# zamiast linii X-Frame-Options
add_header Content-Security-Policy "frame-ancestors https://moodle.example.edu" always;

Wymień w frame-ancestors każdą platformę, która ma prawo Cię osadzić, i usuń X-Frame-Options, żeby nagłówki nie mogły sobie przeczyć. Innego Content-Security-Policy tu nie ma — patrz niżej — więc ten jeden nagłówek jest całą polityką ramek, jaką dostajesz domyślnie.

Nie ma Content-Security-Policy i jest to decyzja

Obraz Client wpisuje swoją konfigurację uruchomieniową do index.html jako skrypt inline przy starcie kontenera — i to właśnie pozwala jednemu obrazowi obsłużyć dowolną instalację — a polityka na tyle ścisła, żeby była coś warta, potrzebowałaby na nim nonce'a. Client żadnego nie emituje, więc CSP albo zepsułby aplikację, albo byłby unsafe-inline, czyli nagłówkiem, który nic nie mówi. Zamknięcie tego należy do Clienta.

Dwa różne originy

Client i Server mogą też stać na różnych originach i to działa. Wtedy API_BASE_URL to publiczny adres Servera, APP_BASE_URL to adres, pod którym przeglądarka sięga po Clienta, a origin Clienta trafia do AJ_Cors__AllowedOrigins Servera.

Musi to być ta sama witryna: jedna domena rejestrowalna, jeden schemat

https://algojudge.example obok https://api.algojudge.example to jedna witryna — inne originy, ta sama domena rejestrowalna — i wszystko działa. https://algojudge.example obok https://algojudge-api.inna to dwie i wtedy nikt się nie zaloguje.

Ciasteczko sesji ma SameSite=Lax. Przeglądarka nie zapisze takiego ciasteczka, gdy odpowiedź, która je ustawia, przychodzi z innej witryny — więc logowanie zawodzi w sposób najtrudniejszy do rozpoznania: POST /identity/login odpowiada 200 i odsyła Set-Cookie, przeglądarka nie zapisuje nic, kolejne żądanie dostaje 401, a aplikacja zostaje na ekranie logowania bez żadnego błędu.

Atrybut Domain tego nie ratuje. Domain decyduje, do jakich hostów ciasteczko jest wysyłane, a serwer może poszerzyć je najwyżej do własnej domeny nadrzędnej — api.algojudge.example nie ustawi ciasteczka dla algojudge-api.inna. W obrębie jednej witryny jest zresztą zbędny: na dwóch nazwach jednej domeny rysunki w treści zadania narysowały się z originu API przy ciasteczku host-only, a poszerzenie Domain sprawiło tylko tyle, że sesja zaczęła jechać na host Clienta przy każdym żądaniu o zasób.

Każdy układ przetrwa to, co nie potrzebuje sesji w ogóle: znak firmowy instalacji, krój pisma i dokumenty publikowane przez operatora są plikami publicznymi i wczytają się skądkolwiek.

Adres API nie może wysyłać X-Frame-Options

To ta część dwóch originów, która z sesją nie ma nic wspólnego, a wchodzi się w nią wprost z sekcji o tym, które nagłówki przekazywać: dołączany security-headers.conf wysyła X-Frame-Options: SAMEORIGIN, więc postawienie tych samych nagłówków przed API psuje treści zadań w PDF.

Client rysuje taką treść znacznikiem <object data="…/api/v1/files/…">. <object> osadza dokument dokładnie tak jak <iframe>, a X-Frame-Options obejmuje oba — przy dwóch originach API jest obcym originem i SAMEORIGIN je odrzuca.

Odmowa jest cicha

Blokując <object>, X-Frame-Options nie pisze nic do konsoli; wariant Content-Security-Policy pisze. Człowiek widzi treść zapasową <object> — pusty prostokąt z odnośnikiem pobierz — i czystą konsolę, czyli dokładnie tak, jak wyglądałoby zadanie opublikowane bez treści. Zmierzone 2026-09-08 w Chromium, ten sam dokument przy czterech wartościach nagłówka.

Na adresie API albo nie wysyłaj polityki ramek wcale, albo wyślij frame-ancestors wymieniające origin Clienta i każdą platformę LMS — przy starcie LTI łańcuch przodków zawiera oba. Brak polityki da się tu obronić: osadzenie API nie daje napastnikowi niczego, bo ciasteczko sesji jest SameSite=Lax, a osadzający z dowolnego innego miejsca dostaje 401.

Lista dozwolonych originów

Ten stos nie ustawia AJ_Cors__AllowedOrigins z tego samego powodu, dla którego nie ustawia AJ_PublicApiUrl: pusty wpis listy to nie to samo, co brak listy. Napisz nakładkę.

# state/origins.compose.yaml
services:
  server:
    environment:
      AJ_Cors__AllowedOrigins__0: https://algojudge.example

Jeden indeks na origin — AJ_Cors__AllowedOrigins=a,b nie wiąże żadnego, po cichu. Znacznik <img> nie potrzebuje CORS w ogóle, więc rysunek może się narysować, gdy wszystkie wywołania API są odrzucane; objawem brakującego originu jest ekran offline, a nie brakujący obrazek.

Certyfikaty

Nic w stosie nie wystawia certyfikatu, a przy własnym proxy z przodu dołączony nginx w ogóle nie działa — TLS jest Twoje. Jeśli używasz dołączonej krawędzi, /.well-known/acme-challenge/ jest serwowane na obu portach, więc wyzwanie HTTP-01 nigdy nie zostanie przekierowane.

Na tej stronie