SOP (Same-Origin Policy) jest mechanizmem bezpieczeństwa przeglądarek internetowych
polegający na odizolowaniu od siebie zasobów różnego pochodzenia. Każdy zasób webowy ma źródło, z którego pochodzi tzw. origin. Jeden unikalny origin definiują jego 3 główne składowe tj. protokół, domena i port.

Przykładowo

http://api.example.com/app.js
http://api.example.com:8090/app.js
http://example.com/app.js
https://example.com/app.js

są różnymi originami mimo, że mogą wskazywać na ten sam zasób. Z drugiej strony

http://example.com/example1.html
http://example.com/example2.html

są różnymi zasobami jednak pochodzą z tego samego origin'a.

W praktyce pod jednym origin zwykle mamy aplikację webową
składającą się z wielu różnych zasobów aby jej funkcjonowanie było możliwe.

Przeglądarka implementując SOP w podstawowym zakresie zabezpiecza naszą aplikację
przed różnego rodzaju potencjalnym zagrożeniem ze strony bezpieczeństwa gdzie inna aplikacja np. stworzona przez atakującego mogłaby z wykorzystaniem AJAX wywoływać metody w imieniu użytkownika naszej aplikacji np. kupić produkt w sklepie uprzednio zmieniając adres wysyłki na atakującego czyli tzw. atak CSRF (Cross-site request forgery). Warto wspomnieć, że nie należy polegać jedynie na tym mechaniźmie i stosować różnego rodzaju zabezpiczenia po stronie serwera tj.  csrf token, walidacja origin, nagłówki access control i inne zabezpieczenia.

W celu przetestowania mechanizmu SOP wystarczy wejść na dowolną stronę np. google.com otworzyć narzędzia developerskie w zakładce Console i wysłać żądanie Ajaxowe na inny origin:

var req = new XMLHttpRequest();
req.open('GET', 'http://localhost:8081', false);
req.send();
Access to XMLHttpRequest at 'http://localhost:8081/' from origin 'https://www.google.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

Jak widać przeglądarka zablokowała możliwość odczytania / dostępu do odpowiedzi z uwagi na inny origin, z którego pobieramy zasób niż ten, w kontekście którego aktualnie się znajdujemy czyli google.com. Warto zaznaczyć, że przeglądarka jedynie zablokowała dostęp do odpowiedzi natomiast samo żądanie zostało wysłane poprawnie. Wynika to z faktu, że przeglądarka dzieli żądania na „proste” czyli np. wykonywane w kontekście img, iframe, style, form i „złożone” np. typu PUT, DELETE (więcej o różnicy tutaj).

Wiemy już dlaczego nie możemy polegać tylko na mechaniźmie SOP. Atakujący w łatwy sposób mógłby spreparować tzw. „proste” żądanie przygotowując np. odpowiedni
formularz, który automatycznie by został wysłany do serwera zmieniając stan systemu w niekontrolowany sposób.

Z uwagi na to, że SOP jest dość restrykcyjny ponieważ pozwala na dostęp do zasobów tylko z tego samego origin, CORS (Cross-Origin Resource Sharing) ma za zadanie zluzować tą sztywną politykę i umożliwić za pomocą odpowiedniej konfiguracji na bezproblemową komunikację pomiędzy różnymi origin'ami.

Przykładowo aby odblokować dostęp do odpowiedzi, która wcześniej była przyblokowana przez przeglądarkę możemy po stronie serwera w odpowiedzi ustawić nagłówek w taki sposób:

Access-Control-Allow-Origin: *

W ten sposób zapewnimy dostęp dla danego zasobu wszystkim aplikacjom (origins), które będą chciały uzyskać do niego dostęp. Zwykle powyższy nagłówek ustawiany jest w taki sposób dla zasobów statycznych dostępnych publicznie np. biblioteki javascriptowe. Jest to typowe dla sieci CDN:

$ curl -I https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js

HTTP/2 200 
content-type: application/javascript; charset=utf-8
access-control-allow-origin: *
server: cloudflare
...

Przykładowa konfiguracja CORS w Springu

Może się zdarzyć tak, że aplikacja kliencka po stronie przeglądarki (np. Angular) będzie musiała komunikować się z api serwera w innym origin niż jest serwowana np. aplikacja napisana w Angular jest dostępna pod adresem https://example.com i komunikuje się z backendem poprzez https://api.example.com. Wiemy już, że są to dwa różne origin'y w związku z tym aby komunikacja przebiegła pomyślnie musimy w taki sposób skonfigurować backend aby zezwolić na taką komunikację.

Rozpoczniemy od zdefiniowania beana odpowiedzialnego za dostarczenie ww. konfiguracji:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@ConditionalOnProperty(prefix = "cors", name = "allowedOrigins")
public class CorsConfiguration {

    @Bean
    public CorsConfigurationSource corsConfigurationSource(
            @Value("${cors.allowedOrigins}") List allowedOrigins
    ) {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        org.springframework.web.cors.CorsConfiguration corsConfiguration = new org.springframework.web.cors.CorsConfiguration();
        corsConfiguration.setAllowedOrigins(allowedOrigins);
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.setAllowedMethods(List.of(
                HttpMethod.GET.name(),
                HttpMethod.HEAD.name(),
                HttpMethod.POST.name(),
                HttpMethod.PUT.name(),
                HttpMethod.DELETE.name()
        ));
        corsConfiguration.setMaxAge(3600L);
        source.registerCorsConfiguration("/**", corsConfiguration);
        return source;
    }
}

Następnie stworzymy adnotację pomocniczą, która zaimportuje nam ww. konfigurację.
Zakładam, że konfiguracja będzie w jednej z bibliotek wspólnych w związku z tym pozwoli to nam ją włączać w zależności od potrzeb dla modułów, które wystawiają api restowe.

import org.springframework.context.annotation.Import;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Import({CorsConfiguration.class})
public @interface EnableCorsConfiguration {
}

Po czym wykorzystamy ww. konfigurację:

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

@Configuration
@EnableResourceServer
@EnableCorsConfiguration
public class ExampleSecurityConfiguration extends ResourceServerConfigurerAdapter {
    
    @Value("${oauth.resourceId}")
    private String resourceId;

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.requestMatchers().antMatchers("/**")
                    .anyRequest().authenticated().and().cors();
    }
    
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId(resourceId);
    }
}

Na koniec w pliku application.yaml zdefiniujemy na jakie origin'y zezwalamy:

cors:
  allowedOrigins: https://example.com

Od teraz aplikacja kliencka bez problemu powinna komunikować się z backendem.

Warto wspomnieć, że jeżeli przed naszymi aplikacjami backendowymi w Java stoi jakiś reverse proxy np. Nginx tam również byłaby możliwa konfiguracja poprzez manipulację nagłówkami.

Na koniec warto zastanowić się czy architektura, w której API mamy na oddzielnej domenie faktycznie ma sens. Przeniesienie go na główną domenę może zredukować liczbę wykonywanych żądań nawet o połowę (z uwagi na Preflight Requests) co na pewno odciąży nasz serwer.