From 1cf2dd2a47ecd890ecd89748460793b9196886cf Mon Sep 17 00:00:00 2001 From: Fenqin Zhou Date: Tue, 28 Jul 2026 13:30:28 +0800 Subject: [PATCH 1/2] Fix CWE-798: remove hard-coded credentials and enforce least privilege Externalize Oracle/PostgreSQL credentials to environment variables (.env), drop the DBA grant and over-broad ANY privileges in favor of least-privilege grants, generate a strong random password in azure-setup.ps1 instead of hard-coding, stop printing the password to stdout, and update README to the env-based setup. Adds .env.example and git-ignores .env. --- .env.example | 12 +++++ .gitignore | 5 +++ README.md | 37 +++++++++------- azure-setup.ps1 | 31 ++++++++++--- docker-compose.yml | 10 ++--- oracle-init/01-create-user.sql | 29 ++++++------ oracle-init/create-user.sh | 44 ++++++++++++------- .../resources/application-docker.properties | 7 +-- src/main/resources/application.properties | 8 ++-- 9 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..bea79ce87 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy this file to ".env" and set strong, unique values before running +# `docker compose up`. The ".env" file is git-ignored and must never be +# committed. Do NOT reuse these placeholder values. + +# Oracle SYS/SYSTEM administrator password (required) +ORACLE_PASSWORD=replace-with-a-strong-password + +# Application database schema user (the user name is not a secret) +APP_USER=photoalbum + +# Application database schema password (required) +APP_USER_PASSWORD=replace-with-a-strong-password diff --git a/.gitignore b/.gitignore index fb4d0fdac..7abf6d8e2 100644 --- a/.gitignore +++ b/.gitignore @@ -198,6 +198,11 @@ __pycache__/ # tools/** # !tools/packages.config +# Local environment / secrets (never commit real credentials) +.env +.env.* +!.env.example + # Tabs Studio *.tss diff --git a/README.md b/README.md index 1e4200d9f..82dcaf1ee 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,12 @@ A photo gallery application built with Spring Boot and Oracle Database, featurin cd PhotoAlbum-Java ``` -2. **Start the application**: +2. **Configure credentials and start the application**: ```bash - # Use docker-compose directly + # Copy the template and set strong, unique passwords in .env (git-ignored) + cp .env.example .env + + # Use docker-compose directly (credentials are read from .env) docker-compose up --build -d ``` @@ -66,7 +69,7 @@ A photo gallery application built with Spring Boot and Oracle Database, featurin - `5500` (Enterprise Manager) - mapped to host port 5500 - **Database**: `XE` (Express Edition) - **Schema**: `photoalbum` -- **Username/Password**: `photoalbum/photoalbum` +- **Username/Password**: provided via the `APP_USER` / `APP_USER_PASSWORD` variables in `.env` (never hard-coded) ## Photo Album Java Application - **Port**: `8080` (mapped to host port 8080) @@ -124,17 +127,21 @@ The application creates the following table structure in Oracle: ### Running Locally (without Docker) 1. **Install Oracle Database** (or use Oracle XE) -2. **Create database user**: +2. **Create database user** (choose your own strong password; grant least + privilege only — do NOT grant DBA): ```sql - CREATE USER photoalbum IDENTIFIED BY photoalbum; - GRANT CONNECT, RESOURCE, DBA TO photoalbum; + CREATE USER photoalbum IDENTIFIED BY ""; + GRANT CREATE SESSION, CREATE TABLE, CREATE SEQUENCE, CREATE VIEW, + CREATE PROCEDURE, CREATE TRIGGER, CREATE TYPE, CREATE SYNONYM + TO photoalbum; + ALTER USER photoalbum QUOTA UNLIMITED ON USERS; ``` -3. **Update application.properties**: - ```properties - spring.datasource.url=jdbc:oracle:thin:@localhost:1521:XE - spring.datasource.username=photoalbum - spring.datasource.password=photoalbum - spring.jpa.hibernate.ddl-auto=create +3. **Provide credentials via environment variables** (do not hard-code them + in `application.properties`): + ```bash + export SPRING_DATASOURCE_URL="jdbc:oracle:thin:@localhost:1521:XE" + export SPRING_DATASOURCE_USERNAME="photoalbum" + export SPRING_DATASOURCE_PASSWORD="" ``` 4. **Run the application**: ```bash @@ -165,8 +172,8 @@ java -jar target/photo-album-1.0.0.jar 2. **Database connection errors**: ```bash - # Verify Oracle is ready - docker exec -it photoalbum-oracle sqlplus photoalbum/photoalbum@//localhost:1521/XE + # Verify Oracle is ready (credentials come from your .env values) + docker exec -it photoalbum-oracle sh -c 'sqlplus "$APP_USER/$APP_USER_PASSWORD@//localhost:1521/FREEPDB1"' ``` 3. **Permission errors**: @@ -207,7 +214,7 @@ docker-compose down -v Oracle Enterprise Manager is available at `http://localhost:5500/em` for database administration: - **Username**: `system` -- **Password**: `photoalbum` +- **Password**: the `ORACLE_PASSWORD` value you set in `.env` - **Container**: `XE` ## Performance Notes diff --git a/azure-setup.ps1 b/azure-setup.ps1 index b83c4dea4..fd82b3eeb 100644 --- a/azure-setup.ps1 +++ b/azure-setup.ps1 @@ -9,6 +9,19 @@ $NC = "`e[0m" # No Color Write-Host "${GREEN}=== Azure Photo Album Resources Setup ===${NC}" -NoNewline Write-Host "" +# Generates a strong random password using a cryptographic RNG. +# Excludes quote/backslash characters so the value is safe to embed in SQL +# and shell/env contexts. +function New-StrongPassword { + param([int]$Length = 24) + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#%*+-_=.' + $bytes = New-Object 'System.Byte[]' $Length + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } + $chars = foreach ($b in $bytes) { $alphabet[$b % $alphabet.Length] } + -join $chars +} + # Variables $RANDOM_SUFFIX = -join ((1..3) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 256) }) $RESOURCE_GROUP = "photo-album-resources-${RANDOM_SUFFIX}" @@ -17,11 +30,15 @@ $ACR_NAME = "photoalbumacr$(Get-Random -Maximum 99999)" $AKS_NODE_VM_SIZE = "Standard_D8ds_v5" $POSTGRES_SERVER_NAME = "$RESOURCE_GROUP-postgresql" $PostgreSQL_SKU = "Standard_D4ads_v5" -$POSTGRES_ADMIN_USER="photoalbum_admin" -$POSTGRES_ADMIN_PASSWORD="P@ssw0rd123!" $POSTGRES_DATABASE_NAME="photoalbum" -$POSTGRES_APP_USER="photoalbum" -$POSTGRES_APP_PASSWORD="photoalbum" + +# Credentials are NOT hard-coded. They are taken from environment variables +# when provided, otherwise a strong random password is generated per run. +# The generated values are written only to the git-ignored .env file below. +$POSTGRES_ADMIN_USER = if ($env:POSTGRES_ADMIN_USER) { $env:POSTGRES_ADMIN_USER } else { "photoalbum_admin" } +$POSTGRES_ADMIN_PASSWORD = if ($env:POSTGRES_ADMIN_PASSWORD) { $env:POSTGRES_ADMIN_PASSWORD } else { New-StrongPassword } +$POSTGRES_APP_USER = if ($env:POSTGRES_APP_USER) { $env:POSTGRES_APP_USER } else { "photoalbum" } +$POSTGRES_APP_PASSWORD = if ($env:POSTGRES_APP_PASSWORD) { $env:POSTGRES_APP_PASSWORD } else { New-StrongPassword } Write-Host "${YELLOW}Using default subscription...${NC}" -NoNewline Write-Host "" @@ -192,7 +209,7 @@ try { --admin-user "$POSTGRES_ADMIN_USER" ` --admin-password "$POSTGRES_ADMIN_PASSWORD" ` --database-name "postgres" ` - --querytext "CREATE USER photoalbum WITH PASSWORD 'photoalbum';" + --querytext "CREATE USER $POSTGRES_APP_USER WITH PASSWORD '$POSTGRES_APP_PASSWORD';" } catch { Write-Host "${YELLOW}User may already exist, continuing...${NC}" -NoNewline Write-Host "" @@ -295,11 +312,11 @@ Write-Host "AKS Cluster: $RESOURCE_GROUP-aks" Write-Host "PostgreSQL Server: $POSTGRES_SERVER_NAME" Write-Host "Location: $LOCATION" Write-Host "" -Write-Host "${GREEN}PostgreSQL Connection Details (stored in environment variables and .env file):${NC}" -NoNewline +Write-Host "${GREEN}PostgreSQL Connection Details (stored in the git-ignored .env file):${NC}" -NoNewline Write-Host "" Write-Host "POSTGRES_SERVER: $env:POSTGRES_SERVER" Write-Host "POSTGRES_USER: $env:POSTGRES_USER" -Write-Host "POSTGRES_PASSWORD: $env:POSTGRES_PASSWORD" +Write-Host "POSTGRES_PASSWORD: ******** (see .env file)" Write-Host "POSTGRES_CONNECTION_STRING: $env:POSTGRES_CONNECTION_STRING" Write-Host "" Write-Host "${GREEN}All configuration has been saved to .env file in the project root.${NC}" -NoNewline diff --git a/docker-compose.yml b/docker-compose.yml index 2d74e35fe..ac32116bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,9 +4,9 @@ services: image: gvenzl/oracle-free:latest container_name: photoalbum-oracle environment: - - ORACLE_PASSWORD=photoalbum - - APP_USER=photoalbum - - APP_USER_PASSWORD=photoalbum + - ORACLE_PASSWORD=${ORACLE_PASSWORD:?ORACLE_PASSWORD must be set (copy .env.example to .env)} + - APP_USER=${APP_USER:-photoalbum} + - APP_USER_PASSWORD=${APP_USER_PASSWORD:?APP_USER_PASSWORD must be set (copy .env.example to .env)} ports: - "1521:1521" volumes: @@ -30,8 +30,8 @@ services: environment: - SPRING_PROFILES_ACTIVE=docker - SPRING_DATASOURCE_URL=jdbc:oracle:thin:@oracle-db:1521/FREEPDB1 - - SPRING_DATASOURCE_USERNAME=photoalbum - - SPRING_DATASOURCE_PASSWORD=photoalbum + - SPRING_DATASOURCE_USERNAME=${APP_USER:-photoalbum} + - SPRING_DATASOURCE_PASSWORD=${APP_USER_PASSWORD:?APP_USER_PASSWORD must be set (copy .env.example to .env)} ports: - "8080:8080" depends_on: diff --git a/oracle-init/01-create-user.sql b/oracle-init/01-create-user.sql index 32ad600e6..aee15762a 100644 --- a/oracle-init/01-create-user.sql +++ b/oracle-init/01-create-user.sql @@ -1,15 +1,18 @@ --- This script runs automatically when Oracle XE container starts --- It creates the photoalbum user and grants necessary privileges +-- This script runs automatically when the Oracle container starts. +-- +-- The application schema user is created by the container itself from the +-- APP_USER / APP_USER_PASSWORD environment variables (see docker-compose.yml +-- and .env). Credentials are intentionally NOT hard-coded here. +-- +-- This script only grants the least set of schema-scoped privileges the +-- application (Hibernate with ddl-auto=create) needs to manage its own +-- objects. It deliberately does NOT grant DBA or any system-wide "ANY" +-- privileges. ALTER SESSION SET "_ORACLE_SCRIPT"=true; --- Create photoalbum user -CREATE USER photoalbum IDENTIFIED BY photoalbum; - --- Grant system privileges -GRANT CONNECT TO photoalbum; -GRANT RESOURCE TO photoalbum; -GRANT DBA TO photoalbum; +-- Least-privilege grants for the application schema user. +-- NOTE: keep this user name in sync with APP_USER in docker-compose.yml / .env. GRANT CREATE SESSION TO photoalbum; GRANT CREATE TABLE TO photoalbum; GRANT CREATE SEQUENCE TO photoalbum; @@ -18,13 +21,9 @@ GRANT CREATE PROCEDURE TO photoalbum; GRANT CREATE TRIGGER TO photoalbum; GRANT CREATE TYPE TO photoalbum; GRANT CREATE SYNONYM TO photoalbum; -GRANT UNLIMITED TABLESPACE TO photoalbum; --- Grant object privileges needed by Hibernate -GRANT SELECT ANY DICTIONARY TO photoalbum; -GRANT CREATE ANY INDEX TO photoalbum; -GRANT ALTER ANY INDEX TO photoalbum; -GRANT DROP ANY INDEX TO photoalbum; +-- Allow the user to store objects in its default tablespace. +ALTER USER photoalbum QUOTA UNLIMITED ON USERS; -- Set default and temporary tablespace ALTER USER photoalbum DEFAULT TABLESPACE USERS; diff --git a/oracle-init/create-user.sh b/oracle-init/create-user.sh index c15ee5f4c..78d3f7b92 100644 --- a/oracle-init/create-user.sh +++ b/oracle-init/create-user.sh @@ -1,30 +1,42 @@ #!/bin/bash -# This script ensures the photoalbum user is created in Oracle XE +# Ensures the application schema user exists in Oracle. +# +# Credentials are read from environment variables (provided by the container +# from docker-compose.yml / .env) and are NOT hard-coded in this file. +# ORACLE_PASSWORD - SYS/SYSTEM administrator password +# APP_USER - application schema user name (default: photoalbum) +# APP_USER_PASSWORD - application schema user password +set -euo pipefail + +: "${ORACLE_PASSWORD:?ORACLE_PASSWORD must be set}" +: "${APP_USER:=photoalbum}" +: "${APP_USER_PASSWORD:?APP_USER_PASSWORD must be set}" # Wait for Oracle to be fully ready echo "Waiting for Oracle to be ready..." sleep 30 -# Connect to Oracle as SYSTEM and create the photoalbum user -sqlplus -s system/photoalbum@//localhost:1521/XE < Date: Tue, 28 Jul 2026 13:58:53 +0800 Subject: [PATCH 2/2] Fix CWE-862/CWE-400 in PhotoAlbum-Java Require authentication for state-changing endpoints (/upload, /detail/*/delete) via Spring Security with env-based admin credentials; read image dimensions from header with a max-pixel cap to prevent decompression-bomb DoS. --- .env.example | 5 ++ docker-compose.yml | 2 + pom.xml | 6 ++ .../com/photoalbum/config/SecurityConfig.java | 63 +++++++++++++++++++ .../service/impl/PhotoServiceImpl.java | 35 ++++++++--- .../resources/application-test.properties | 6 +- 6 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/photoalbum/config/SecurityConfig.java diff --git a/.env.example b/.env.example index bea79ce87..36724e8bd 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,8 @@ APP_USER=photoalbum # Application database schema password (required) APP_USER_PASSWORD=replace-with-a-strong-password + +# Admin account used to authenticate photo upload/delete (state-changing endpoints). +# The user name is not a secret; the password is required and must be strong. +APP_ADMIN_USERNAME=admin +APP_ADMIN_PASSWORD=replace-with-a-strong-password diff --git a/docker-compose.yml b/docker-compose.yml index ac32116bd..8103c17a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,8 @@ services: - SPRING_DATASOURCE_URL=jdbc:oracle:thin:@oracle-db:1521/FREEPDB1 - SPRING_DATASOURCE_USERNAME=${APP_USER:-photoalbum} - SPRING_DATASOURCE_PASSWORD=${APP_USER_PASSWORD:?APP_USER_PASSWORD must be set (copy .env.example to .env)} + - APP_ADMIN_USERNAME=${APP_ADMIN_USERNAME:-admin} + - APP_ADMIN_PASSWORD=${APP_ADMIN_PASSWORD:?APP_ADMIN_PASSWORD must be set (copy .env.example to .env)} ports: - "8080:8080" depends_on: diff --git a/pom.xml b/pom.xml index 216b79d5f..398340a31 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,12 @@ spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + commons-io diff --git a/src/main/java/com/photoalbum/config/SecurityConfig.java b/src/main/java/com/photoalbum/config/SecurityConfig.java new file mode 100644 index 000000000..c9a6fb271 --- /dev/null +++ b/src/main/java/com/photoalbum/config/SecurityConfig.java @@ -0,0 +1,63 @@ +package com.photoalbum.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Security configuration. + * + * The photo gallery is publicly readable, but the state-changing endpoints + * (photo upload and delete) require authentication so anonymous callers can no + * longer create or destroy data (CWE-862 / CWE-306). The admin credentials are + * supplied via environment variables (app.admin.username / app.admin.password), + * never hard-coded. + */ +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public InMemoryUserDetailsManager userDetailsService( + PasswordEncoder passwordEncoder, + @Value("${app.admin.username:admin}") String adminUsername, + @Value("${app.admin.password}") String adminPassword) { + UserDetails admin = User.withUsername(adminUsername) + .password(passwordEncoder.encode(adminPassword)) + .roles("ADMIN") + .build(); + return new InMemoryUserDetailsManager(admin); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // Stateless HTTP Basic auth: credentials are sent per request, so a + // session-based CSRF token is not applicable. CSRF is disabled to + // keep this security fix minimal without breaking the JSON upload API. + .csrf().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + // Default deny for state-changing operations (CWE-862 / CWE-306). + .antMatchers(HttpMethod.POST, "/upload", "/detail/*/delete").authenticated() + // Public, read-only photo gallery. + .anyRequest().permitAll() + .and() + .httpBasic(); + return http.build(); + } +} diff --git a/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java b/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java index fa379a51a..4c435e9fc 100644 --- a/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java +++ b/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java @@ -12,10 +12,12 @@ import org.springframework.web.multipart.MultipartFile; import javax.imageio.ImageIO; -import java.awt.image.BufferedImage; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Arrays; +import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -29,6 +31,9 @@ public class PhotoServiceImpl implements PhotoService { private static final Logger logger = LoggerFactory.getLogger(PhotoServiceImpl.class); + // Maximum decoded image size (pixels) to prevent decompression-bomb DoS (CWE-400). + private static final long MAX_IMAGE_PIXELS = 40_000_000L; + private final PhotoRepository photoRepository; private final long maxFileSizeBytes; private final List allowedMimeTypes; @@ -118,12 +123,28 @@ public UploadResult uploadPhoto(MultipartFile file) { // Read file content for database storage photoData = file.getBytes(); - // Extract image dimensions from byte array - try (ByteArrayInputStream bis = new ByteArrayInputStream(photoData)) { - BufferedImage image = ImageIO.read(bis); - if (image != null) { - width = image.getWidth(); - height = image.getHeight(); + // Read image dimensions from the header only (no full decode) and + // enforce a maximum pixel count to prevent decompression-bomb DoS + // (CWE-400). ImageIO.read() would fully decode attacker-controlled + // bytes and can exhaust the JVM heap. + try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(photoData))) { + Iterator readers = (iis == null) ? null : ImageIO.getImageReaders(iis); + if (readers != null && readers.hasNext()) { + ImageReader reader = readers.next(); + try { + reader.setInput(iis, true, true); + int w = reader.getWidth(0); + int h = reader.getHeight(0); + if ((long) w * (long) h > MAX_IMAGE_PIXELS) { + result.setSuccess(false); + result.setErrorMessage("Image dimensions exceed the allowed limit."); + return result; + } + width = w; + height = h; + } finally { + reader.dispose(); + } } } } catch (IOException ex) { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 41da82b12..116da1abd 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -16,4 +16,8 @@ app.file-upload.allowed-mime-types=image/jpeg,image/png,image/gif,image/webp app.file-upload.max-files-per-upload=10 # Logging for tests -logging.level.com.photoalbum=DEBUG \ No newline at end of file +logging.level.com.photoalbum=DEBUG + +# Admin credentials for the test context only (not a real secret) +app.admin.username=admin +app.admin.password=test-admin-password \ No newline at end of file