expérience / septembre 2024

Backend development and hardening at EYECOM

Professional Java/Spring work on internal APIs, modernization, deployment automation, and security hardening.

JavaSpringAPIDeploymentHardening

Between December 2023 and September 2024, I worked as a junior developer at EYECOM ALGERIE. The work was not a lab exercise. It was backend development in a professional environment, with an existing codebase, changing requirements, and real users who needed the system to keep running while I made changes to it.

Most of my work touched Java/Spring APIs, modernization of internal tools, deployment automation, and security hardening. But before getting into what I did, it is worth describing what I inherited, because the shape of the existing system is why the security work had the character it did.

The existing codebase

The backend was Java 11 with Spring Boot 2.3. Some services used Spring MVC for REST endpoints and Spring Data JPA for persistence. Authentication was session-based in the older modules and JWT-based in the newer ones, but both systems ran simultaneously on overlapping codepaths. The newer JWT-secured endpoints sat alongside older session-secured ones, and the Spring Security configuration was defined per-module with no central policy that applied consistently across the application.

The first thing I did was map what was actually exposed. Not a formal audit, just reading controller classes and their @RequestMapping annotations. What I found is common in codebases that grow incrementally: the public-facing routes had authentication applied carefully, but internal and administrative endpoints had been added under paths like /internal/admin/... with the assumption that only trusted internal services would ever reach them. That assumption was never encoded in the security configuration. Nothing actually prevented a request from the wrong origin from reaching those routes.

A related issue was that Spring Actuator endpoints were enabled in production. Actuator exposes health checks, metrics, thread dumps, environment variables, and configuration properties through HTTP. The /actuator/env endpoint returns all resolved application properties, including values loaded from environment variables — database connection strings, API keys, internal service tokens. The endpoints were not listed in any documentation visible to me when I started, but they were there and reachable.

Hardening the endpoint surface

Fixing the unauthorized endpoint problem meant two things. First, I added method-level authorization using @PreAuthorize on all internal endpoint handlers. These annotations integrate with Spring Security’s expression language:

@PreAuthorize("hasRole('INTERNAL_SERVICE')")
@PostMapping("/internal/admin/sync")
public ResponseEntity<?> syncData(...) { ... }

This requires any caller to have the INTERNAL_SERVICE role in their security context. A request that reaches this endpoint without valid credentials gets a 403 before the handler method runs.

Second, I updated the SecurityFilterChain configuration to deny by default: any request path not explicitly permitted was rejected rather than allowed. The old configuration was a allowlist of secured paths; everything else fell through unauthenticated. The new configuration inverted this — everything is denied unless there is an explicit rule permitting it.

Service-to-service authentication used a shared secret in a custom HTTP header, validated by a filter early in the chain. This is not the correct long-term architecture for service-to-service auth — mutual TLS, where both sides present client certificates, is the right pattern. But mTLS requires certificate issuance and rotation infrastructure that was not in scope. The header-based token was a practical improvement over no check at all.

Actuator endpoints got a targeted rule restricting /actuator/** to requests from the loopback address only. Containerized deployment makes even that imperfect, but it removed the public exposure.

Input validation and SQL injection

Most endpoints passed user-provided fields into Spring Data JPA repositories using @Query with named parameters. Spring Data parameterizes the query before it reaches the database driver, so string parameters are never concatenated into the SQL text. Those endpoints were fine.

The problem was two endpoints where the original developer had used EntityManager.createNativeQuery() with string concatenation. Native queries bypass the parameter-binding safety of JPQL entirely. I found both by searching the codebase for createNativeQuery and reading every call site.

One was building an ORDER BY clause from a query parameter:

// vulnerable version
String query = "SELECT * FROM items ORDER BY " + sortColumn;
entityManager.createNativeQuery(query);

An attacker who controls sortColumn can inject arbitrary SQL. The fix was a whitelist: define an enum of allowed column names, validate the parameter against the enum, and substitute the enum value — not the raw string — into the query.

The other was constructing a WHERE clause from optional filter fields. A nullable filter parameter was being concatenated into the query string when present. The fix was to replace the entire query with a JPA Criteria API equivalent:

CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Item> cq = cb.createQuery(Item.class);
Root<Item> root = cq.from(Item.class);
List<Predicate> predicates = new ArrayList<>();
if (filter != null) {
    predicates.add(cb.equal(root.get("field"), filter));
}
cq.where(predicates.toArray(new Predicate[0]));

The Criteria API builds the predicate programmatically. There is no string concatenation. Null parameters simply do not produce a predicate.

CORS misconfiguration

The development CORS configuration had been left in production. It combined two settings that cannot safely coexist:

config.addAllowedOrigin("*");    // wildcard origin
config.setAllowCredentials(true); // credentials included

Modern browsers reject this combination at the preflight stage: when allowCredentials is true, the Access-Control-Allow-Origin response header cannot be *. The browser enforces this restriction. Non-browser clients — curl, other services, an attacker’s custom HTTP client — are not subject to CORS at all.

The fix was to replace the wildcard with per-environment explicit origin lists, controlled via Spring profiles. The development profile allowed http://localhost:3000. The production profile allowed only the production frontend domain. Each environment’s allowed origin was set in its respective application properties file, and the CORS configuration read from those properties rather than from a hardcoded string.

Dependency modernization

Spring Boot 2.3 had reached end-of-life for security updates. I upgraded to 2.7, the last release in the 2.x line before the major Spring Boot 3 migration. The 2.7 upgrade was not a drop-in replacement.

Several APIs changed: the WebSecurityConfigurerAdapter base class for security configuration was deprecated in favor of a SecurityFilterChain bean approach. The old pattern:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception { ... }
}

became:

@Configuration
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ... }
}

Several property prefixes also changed, and Hibernate schema generation behavior shifted in ways that required review. Working through those changes carefully and running the test suite at each step was the most time-consuming part. Some modules had no integration tests, so I wrote basic smoke tests for the exposed endpoints before touching dependencies in those modules. A smoke test that fails after a library update is much more useful than discovering the regression in a manual QA cycle.

OWASP Dependency-Check scanned the full dependency tree and flagged critical and high CVEs in transitive dependencies. Several came from old Jackson Databind versions pulled in transitively by other libraries. Adding an explicit, patched jackson-databind version to pom.xml overrides the transitive version without waiting for the dependency chain to update naturally.

Deployment automation

When I arrived, deploying meant building a JAR on a developer’s workstation and copying it to the server with SCP. There was no record of when the running binary was built, by whom, or from which commit. It was impossible to know whether the production server was running the code that was in the repository.

I set up a GitHub Actions pipeline that triggered on pushes to main. It built the JAR, ran the test suite, and on a passing build uploaded the artifact to the repository’s release storage with the commit SHA in the artifact name.

Deploying to the server remained a manual step — I did not have permission to configure SSH access for automated deployment. But the artifact now had a known provenance. The operations team could verify that the binary they were deploying matched a specific commit and had passed the test suite at build time. Before, there was no way to make that check.

Why these issues existed

The technical findings above are not unusual. Every production backend has some version of them if it has grown without a consistent security process. The educational part was understanding why.

The unauthorized internal endpoints were added by a developer thinking about the business logic, not about whether the routing was sound. The CORS wildcard was in production because no one ever systematically compared the development configuration to the production configuration. The native SQL queries with string concatenation were written because createNativeQuery was faster under time pressure, and the developer writing them was thinking about meeting a deadline.

Security work in a real application is not about finding sophisticated vulnerabilities. It is about finding the accumulated decisions that made sense at the time but were never revisited from a security perspective. The developer who wrote the concatenated ORDER BY query was not thinking about injection. They were thinking about getting the sort feature to work.

That observation is the most useful thing I took from the job. Understanding why teams bypass controls when those controls add friction makes it possible to design controls that get bypassed less. A security check that requires three extra steps will be skipped when people are under pressure. A check built into the deployment pipeline runs on every commit regardless of pressure.