Fly Proxy WAF is blocking POST requests to /api/authenticate with 403 Forbidden

First, this is not debugging problem! The code works fine but not on fly.io :frowning:
The issue is specific for fly.io architecture!
Second, I asked for help and not for opinion what someone should or shouldn’t do. So, please if you don’t want to help me it is OK but don’t spam this thread with your opinions about something completely off-topic.

Anyway, I found the workaround but I am 100% sure that you are capable to solve similar issue by yourself.

Thanks in advance!

Don’t take it personally. :hugs: My advice isn’t intended to frustrate your efforts in fixing your problem; in fact, it was practical advice to show what you can do to move yourself forward. If you ask a broad question without sufficient clarity (e.g. some initial debugging results) then you could, conceivably, be waiting a very long time for an answer.

Moreover, a surprising number of people building software don’t know how to do the systems analysis to work out what to debug, or, if they do know what to debug, they don’t think in a systematic way that narrows the problem down. That’s not bad or wrong; they just need guidance on how to do that. In this context, I often think of that aphorism about “don’t give a man a fish, teach him how to catch his own”. :fishing_pole:

With that in mind, you may find this advice useful.

(For what it’s worth, AI is making this phenomena worse, but it didn’t start it. The reason Stack Overflow attracts so many experts is that a free-for-all has not been tolerated, at least for the last ten years).

Super. What was it? Maybe someone else will find that advice helpful.

It is not easy to explain the solution in short but I’ll try.
First what I did is to split monolithic app in 2 apps-backend and frontend but without creating two separate projects. It is still one JHipster project but backend is a private and frontend is in DMZ. That requires 2 different dockerfiles and 2 different fly.toml files - frontend and backend. Frontend and backend communicates through proxy configured in nginx.conf file on frontend side.
Now, the question was why it was working on Render and not on Fly.io?

The most important thing is that on Fly.io you have to manually (in the code) handle the
fundamental part of the CORS (Cross-Origin Resource Sharing) standard and which is implemented by all modern web browsers.
So in public class SecurityConfiguration (originaly made by JHipster while creating the project) I had to make some changes and it should look like

@Configuration
@EnableMethodSecurity(securedEnabled = true)
public class SecurityConfiguration {

    private static final Logger log = LoggerFactory.getLogger(SecurityConfiguration.class);
    private final JHipsterProperties jHipsterProperties;

    public SecurityConfiguration(JHipsterProperties jHipsterProperties) {
        this.jHipsterProperties = jHipsterProperties;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    // This bean is no longer strictly needed by the filter chain but is harmless to keep.
    @Bean
    MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
        return new MvcRequestMatcher.Builder(introspector);
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .cors(withDefaults())
            .headers(headers ->
                headers
                    .contentSecurityPolicy(csp -> csp.policyDirectives(jHipsterProperties.getSecurity().getContentSecurityPolicy()))
                    .frameOptions(FrameOptionsConfig::sameOrigin)
                    .referrerPolicy(referrer -> referrer.policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
                    .permissionsPolicyHeader(permissions ->
                        permissions.policy(
                            "camera=(), fullscreen=(self), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), midi=(), payment=(), sync-xhr()"
                        )
                    )
            )
            .authorizeHttpRequests(authz ->
                authz
                    .requestMatchers(HttpMethod.OPTIONS, "/**")
                    .permitAll()
                    .requestMatchers(HttpMethod.POST, "/api/contact-messages")
                    .permitAll()
                    .requestMatchers(
                        HttpMethod.GET,
                        "/api/blog-posts",
                        "/api/blog-posts/**",
                        "/api/skills",
                        "/api/projects",
                        "/api/project-images",
                        "/api/services"
                    )
                    .permitAll()
                    .requestMatchers(
                        "/api/authenticate",
                        "/api/register",
                        "/api/account/reset-password/init",
                        "/api/account/reset-password/finish"
                    )
                    .permitAll()
                    .requestMatchers(HttpMethod.GET, "/management/health", "/management/health/**", "/management/info")
                    .permitAll()
                    .requestMatchers("/api/admin/**", "/management/**")
                    .hasAuthority(AuthoritiesConstants.ADMIN)
                    .requestMatchers("/api/**")
                    .authenticated()
            )
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(exceptions ->
                exceptions
                    .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
                    .accessDeniedHandler(new BearerTokenAccessDeniedHandler())
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));

        return http.build();
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(
            Arrays.asList("http://localhost:9000", "http://localhost:8080", "https://*.fly.dev", "http://*.fly.dev")
        );
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-XSRF-TOKEN"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

And most critical backend (Spring boot) java class which gave me some headaches (JHipster create it wrongly) I had to remove redundant public CorsFilter corsFilter() which was overriding the CORS filter in above class:

@Configuration
public class WebConfigurer implements ServletContextInitializer, WebServerFactoryCustomizer<WebServerFactory> {

    private static final Logger LOG = LoggerFactory.getLogger(WebConfigurer.class);

    private final Environment env;

    private final JHipsterProperties jHipsterProperties;

    public WebConfigurer(Environment env, JHipsterProperties jHipsterProperties) {
        this.env = env;
        this.jHipsterProperties = jHipsterProperties;
    }

    @Override
    public void onStartup(ServletContext servletContext) {
        if (env.getActiveProfiles().length != 0) {
            LOG.info("Web application configuration, using profiles: {}", (Object[]) env.getActiveProfiles());
        }

        LOG.info("Web application fully configured");
    }

    /**
     * Customize the Servlet engine: Mime types, the document root, the cache.
     */
    @Override
    public void customize(WebServerFactory server) {
        // When running in an IDE or with ./mvnw spring-boot:run, set location of the static web assets.
        setLocationForStaticAssets(server);
    }

    private void setLocationForStaticAssets(WebServerFactory server) {
        if (server instanceof ConfigurableServletWebServerFactory servletWebServer) {
            URL resource = getClass().getResource("/static/index.html");

            // Only set the document root if we are running from the file system (protocol is "file")
            if (resource != null && "file".equals(resource.getProtocol())) {
                try {
                    File root = new File(resource.toURI());
                    // The resource is index.html, so we need its parent directory 'static'
                    File staticFolder = new File(root.getParent());
                    servletWebServer.setDocumentRoot(staticFolder);
                } catch (URISyntaxException e) {
                    throw new RuntimeException("Unable to resolve static assets location", e);
                }
            }
            // If the protocol is "jar", we do nothing. Spring Boot will handle it correctly.
        }
    }

// I had to remove this method which gave me the most headaches
//    @Bean
//    public CorsFilter corsFilter() {
//        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
//        CorsConfiguration config = jHipsterProperties.getCors();
//        if (!CollectionUtils.isEmpty(config.getAllowedOrigins()) || !CollectionUtils.isEmpty(config.getAllowedOriginPatterns())) {
//            LOG.debug("Registering CORS filter");
//            source.registerCorsConfiguration("/api/**", config);
//            source.registerCorsConfiguration("/management/**", config);
//            source.registerCorsConfiguration("/v3/api-docs", config);
//            source.registerCorsConfiguration("/swagger-ui/**", config);
//        }
//        return new CorsFilter(source);
//    }
}

On frontend side there should be also nginx.conf (proxy settings for production environment):

# fly/frontend/nginx.conf (DNS + IP fallback, Round-Robin)

server {
    listen 8080;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip Settings
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;

    # API Reverse Proxy with DNS + private IP fallback
    location ~ ^/(api|management|v3/api-docs)/ {
        # Fly.io internal DNS
        resolver [fdaa::3]:53 ipv6=on valid=5s;

        # Backend hostname
#         set $backend_host "zoranstepanoski-prof-api.flycast";
        set $backend_host "zoranstepanoski-prof-api.internal";

        # Fallback backend private IPs (replace with your actual machine IPs)
        set $backend_ip1 "[fdaa:2b:6918:a7b:464:6717:6324:2]";
        set $backend_ip2 "[fdaa:2b:6918:a7b:4e5:13b9:ff5:2]";

        # Standard proxy headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Origin $http_origin;

        # Pass request to backend hostname first
        proxy_pass http://$backend_host:8080;

        # Retry on errors; fall back to private IPs if hostname fails
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 4;
        proxy_next_upstream_timeout 2s;

        # Optional: explicit failover sequence
        # Nginx tries $backend_host first, then IP1, then IP2
        # This uses the built-in "next upstream" mechanism
        # If needed, you can duplicate proxy_pass with different conditions using 'if' blocks.

        # Timeouts
        proxy_connect_timeout 3s;
        proxy_send_timeout 10s;
        proxy_read_timeout 10s;
        proxy_buffering on;
    }

    # SPA Fallback for Angular routing
    location / {
        try_files $uri $uri/ /index.html;
    }
}

The reason why it was working on Render and not on Fly.io is that Render and Fly.io use different proxy technologies with different default configurations and architecture (splitting the backend and frontend).

This code is specific for Spring Boot and JHipster and there are some tweaks on application-prod.yml, Dockerfile for backend and frontend…

So, the code was working but it looks like that the difference between Fly.io and Render architecture was the biggest issue.

Anyway I made it work and thanks to [erlangga] and [lillian] who drew my attention to CORS in public class WebConfigurer.

If anyone else has the similar issues I’ll be glad to help!

2 Likes

To be honest, that looks nasty. I’m glad I’m not dealing w/ Spring.

1 Like

:grinning_face:
Well, I used to use Spring because JHipster is working with it and you can quickly create a working skeleton especially if you use DB in background. But if you want to customize the code later then yes, it can be tricky.
I was working with different version of JHipster and this latest stable version I currently use is quite good. They made some very good improvements, especially on the backend side and project structure.

1 Like

Good level of detail @StepanoskiZ.

I am a bit lost however as to why you have a CORS problem. On your site it looks like you have a couple of API calls initiated by JavaScript, but they are still on the zoranstepanoski-prof-website.fly.dev domain, and thus the CORS security barrier would not kick in. I could not get your login thing to trigger, but is this network operation on a different domain, subdomain, or port?

Thanx!

Well, I had to use CORS because in ‘Contact’ section I use POST to save the new contact and contact text in DB. There the CORS issue occurred.

On prod env zoranstepanoski-prof-website.fly.dev you can see that there is no login to admin page. Login to admin page is possible only from my local machine and that is how I administrate the web-site. You can try something like ZSWebsite but since you are in prod env your request will be rejected. That is how I solved the web-site administration issue. Administration is possible only from dev environment on my local machine :slight_smile: In that case I don’t use nginx.conf proxy needed for fly.io public frontend. In that case I create a virtual tunnel with ‘flyctl proxy 8080:8080 -a zoranstepanoski-prof-api’ where zoranstepanoski-prof-api is my backend but in a private mode, behind the fly.io firewall. As soon as I finish the site I’ll put whole my code to the public on GitHub so anyone who use JHipster and Angular and Maven/Spring can see the whole code.
And just FYI, the whole site works like fly.io (frontend and backend) → Supabase for PostgreSQL DB and Mailjet for sending information mails to me.

Hmm, I just tried the contact page, and it does a POST to https://zoranstepanoski-prof-website.fly.dev/api/contact-messages, but since this is the same as the domain from which the site is served, no access-control-allow-origin should be required.

It sounds from your explanation above that one has to be in dev/local mode in order to bump into the CORS problem you’ve been experiencing; I guess your domains or ports are different between front and back. You could solve this by having a proxy in front of everything, and one that works the same regardless of environment. I’d recommend Traefik for this; you can run Traefik, your frontend, and your backend locally in Docker Compose, and maybe Fly Containers remotely. Since both envs will only have one port/domain each, all CORS problems will go away, and the custom JHipster code can be removed.

Traefik is very clever; it basically works by introspecting the kind of traffic you have, and sending it to the right container. It can automatically tell the difference between frontend serving, backend REST/GraphQL, Websockets, etc.

The root of the problem was that https://zoranstepanoski-prof-website.fly.dev/api/contact-messages has to be public so everyone can send the contact message but only I have the direct access to the backend and DB.

No, they don’t have to. On my dev env on my local machine I didn’t have CORS problem because I was using a tunnel ‘flyctl proxy 8080:8080 -a zoranstepanoski-prof-api’ so there was no CORS extra layer of security (it works similar as VPN - both applications are on the same domain).
fly.io was asking for nginx.conf proxy settings for communication between public frontend and private backend (different domains). Then the CORS 403 error occurred!
In both cases (dev and prod) I use port 8080.
About everything else you mentioned, yes you are probably right but, as I said at earlier, I don’t have much experience with fly.io and I use JHi[ster for quite some time. As soon as I run into CORS issue I created second fly.io and now I have two fly.io applications: zoranstepanoski-prof-website (frontend Fly.io public) and zoranstepanoski-prof-api (backend fly.io private) but all the code is in one JHipster project.
And it works! If anyone tries to hack my site, well OK, it is just my private web-site for self presentation which I intend to use to present myself and all the projects I was working on with detailed explanation of technologies used, purpose of the projects, benefits of the projects and contacts with eventual future customers :slight_smile: My CV is 5 page long (short version) and I had to find a way how to reduce my CV to 2 pages.
This was the only way to make my CV shorter :smiley:

Important to understand is that my frontend on my local machine communicate with backend on fly.io.

I didn’t know that your real name is John Smith!

IMO spring boot giving me sane development environment especially comparing it
NodeJS wildness, well yes using JHipster make things very verbose

first time deploying spring boot a101 issue is always the CORS setup misconfig especially when you use NGINX or some kind of proxy behind it

my guess

FE (zoranstepanoski-prof-website) > Nginx (act as reverse proxy) > Real backend spring (zoranstepanoski-prof-api)

this kind of deployment is normal when you want to hide the real backend and put it behind nginx and do just use path /api <> zoranstepanoski-prof-api connection

the issue raise when NGINX forward the request to zoranstepanoski-prof-api either the Origin is missed (no origin) or wrong origin this cause spring to reject with 403

but IMO you dont need to do this you can just directly call the domain instead reverse proxied it because the Fly has really good metrics in dashboard when you do it raw request im not sure wether you will lose some metrics or not

because this kind of deployment mostly is for enterprise and your cloud architech dont want expose backend ip or your backend live under “On Prem Server” , but im not sure on your case

Ah yes, so once the proxied request arrives at the backend, the Host headers are for something else, and need to be rewritten. That makes sense. In other words, this app was deployed in a broken state.

I remain of the view that this is something that free support teams should not get involved in (I don’t mean Fly here specifically; it would be the same for AWS, GCP, DO, Vultr, etc). Application architecture is expensive, and it simply isn’t scalable to offer this kind of service without charging for it.

Yes, that is exactly how it works! And I just would like to add
FE (zoranstepanoski-prof-website) > Nginx (act as reverse proxy) > Real backend spring (zoranstepanoski-prof-api) > Supabase PostgreSQL DB.
I had a problems at the beginning to set Nginx to act as reverse proxy and CORS issues but I manage to solve it. So, all other problems are pure BE and FE related. Creating Nginx and configuring Nginx and CORS were the biggest problem!

Anyway, thank you all for help!

Yes, this kind of architecture is mostly used for enterprise and probably I overengineered my solution (it is only my profile website) but since I used to work in enterprise environments I used this architecture :slight_smile:

This topic was automatically closed 7 days after the last reply. New replies are no longer allowed.