Share
Embed
Best Practices to Secure Web Applications
While strong authentication and authorization lay the foundation, achieving robust web application security demands a multi-layered approach. At first glance, the field of web application security can seem daunting, but by understanding and implementing core principles, you can dramatically strengthen your defenses against cyber threats.
In this presentation, we'll delve into the essential, yet often neglected, aspects of securing your applications. We'll cover best practices for secure coding, meticulous input validation techniques, the importance of strategic error handling and logging, how to manage file uploads safely, and much more.
Loiane Groner
June 26, 2024
Transcript
68 slides
-
Application Security Plan Development Build Test QA / UAT Production
Security Testing Production Delays due to late assessment Worse: assessment is done after production every X months Source: NIST Ponemom Institute -
Shift left Plan Development Build Test QA / UAT Production
Source: NIST Ponemom Institute Remediation Costs: -
Loiane Groner Development / Engineering Manager | Published Author @loiane
loiane loianegroner loiane.com -
OWASP Top 10 API Security Risks – 2023 Broken Object
Level Authorization Unrestricted Access to Sensitive Business Flows Broken Authentication Server Side Request Forgery Broken Object Property Level Authorization Security Misconfiguration Unrestricted Resource Consumption Improper Inventory Management Broken Function Level Authorization Unsafe Consumption of APIs 1 2 3 4 5 6 7 8 9 10 https://owasp.org/API-Security/editions/2023/en/0x11-t10/ -
Bad Practice: Checking not allowed public CourseDTO update(@PathVariable Long id,
@RequestBody Course course) { User user = getAuthenticatedUser(); if (user.getRole().equals(Role.STUDENT)) { throw new AccessDeniedException("You don't have permission to update course"); } return courseService.update(id, course); } -
Good Practice: Deny by Default @PutMapping(value = "/{id}") public CourseDTO
update(@PathVariable Long id, @RequestBody Course course) { User user = getAuthenticatedUser(); // allow to update if role is admin or teacher if (user.getRole().equals(Role.ADMIN) || user.getRole().equals(Role.TEACHER)) { return courseService.update(id, course); } throw new AccessDeniedException("You don't have permission to update course"); } -
Role-based access control @PreAuthorize("hasRole('ADMIN') or hasRole('TEACHER')") public CourseDTO update(@PathVariable Long
id, @RequestBody Course course) { return courseService.update(id, course); } -
Potential Problem: Role Explosion @PreAuthorize("hasRole('ADMIN') or hasRole('TEACHER') or hasRole('TEACHING_ASSISTANT') or
hasRole('ACCOUNT_MANAGER') ") public CourseDTO update(@PathVariable Long id, @RequestBody Course course) { return courseService.update(id, course); } -
Multiple roles and permissions: Course View Enrol Create Edit Details
Add Lessons Delete Student ✅ ✅ ❌ ❌ ❌ ❌ Teacher ✅ ❌ ✅ ✅ ✅ ❌ Teaching Assistant ✅ ❌ ❌ ❌ ✅ ❌ Account Manager ✅ ❌ ❌ ✅ ✅ ✅ Admin ✅ ❌ ✅ ✅ ✅ ✅ -
Decouple from the logic: custom security expression private boolean hasPrivilege(Authentication
auth, String targetType, String permission) { for (GrantedAuthority grantedAuth : auth.getAuthorities()) { if (grantedAuth.getAuthority().startsWith(targetType) && grantedAuth.getAuthority().contains(permission)) { return true; } } return false; } public class User{ private String username; private String password; private Set<Privilege> privileges; } public class Privilege { private String name; } -
Decouple from the logic: custom security expression No more hardcoding!
@PreAuthorize("hasPrivilege('COURSE_UPDATE_PRIVILEGE')") public CourseDTO update(@PathVariable Long id, @RequestBody Course course) { return courseService.update(id, course); } -
Should a teacher be able to update ANY course? @PreAuthorize("hasAuthority('COURSE_UPDATE_PRIVILEGE')")
public CourseDTO update(@PathVariable Long id, @RequestBody Course course) { return courseService.update(id, course); } Checks only if user is authorized Can update any course as long as you know the ID? Deny by default! -
Broken Object Level Authorization public CourseDTO update(@PathVariable Long id, @RequestBody
Course course) { if (canUpdateCourse(getAuthenticatedUser(), course)) { return courseService.update(id, course); } throw new AccessDeniedException("You do not have permission"); } public static canUpdateCourse(User user, Course course) { if (isAdmin(user) or isAccountManager(user)) return true; // Teacher can update course if they are the teacher of the course if (isTeacher(user) and course.getTeacher().getId() == user.getId()) return true; } From the request! -
Never trust the request! public CourseDTO update(@PathVariable Long id, @RequestBody
Course course) { Course courseFromDB = courseService.findById(id); if (canUpdateCourse(getAuthenticatedUser(), courseFromDB)) { return courseService.update(id, course); } throw new AccessDeniedException("You do not have permission."); } -
Avoid exposing entities @GetMapping("/loggedInUser") public User getMyInfo() { return userService.getMyInfo();
} @Entity public class User { @Id private String userName; private String password; @JsonIgnore public String getPassword() { return password; } } -
Avoid exposing entities @Entity public class User { @Id private
String userName; private String password; private String ssn; // .. } @JsonIgnore public String getPassword() { return password; } public String getSsn() { return ssn; } -
Create DTOs public record UserDTO(String userName) {} @GetMapping("/api/v1/loggedInUser") public UserDTO
getMyInfo() { User user = userService.getMyInfo(); return new UserDTO(user.getUserName()); } -
DTO for request and response? Be careful! public record StudentDTO(Long
id, String userName, double averageGrade) {} @PutMapping("/students/{id}") public StudentDTO update(@PathVariable Long id, @RequestBody StudentDTO studentDTO) { // check for access rights Student student = studentRepository.findById(id).orElseThrow(); student.setUserName(studentDTO.userName()); student.setAverageGrade(studentDTO.averageGrade()); studentRepository.save(student); } Make sure to ignore the studentDTO id for updates if reusing DTO -
Lower environment exposed # spring jpa database MySQL for Dev
enviroment spring.datasource.url=jdbc:mysql://CompanyDEV:3306/learningPlatform?useSSL=false spring.datasource.username=learningPlatformUser spring.datasource.password=learningPlatform@Dev12345! spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect spring.jpa.hibernate.ddl-auto=update Prefer to use a vault solution to retrieve the password -
Lower environment exposed # spring jpa database MySQL for Dev
enviroment spring.datasource.url=jdbc:mysql://CompanyDEV:3306/learningPlatform?useSSL=false spring.datasource.username=learningPlatformUser spring.datasource.password=learningPlatform@Dev12345! spring.datasource.driver-class-name=com.mysql.jdbc.Driver spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect spring.jpa.hibernate.ddl-auto=update Don't use user with DDL access -
Front-end: fully validated! this.form = this.formBuilder.group({ name: [ course.name, [Validators.required,
Validators.minLength(5), Validators.maxLength(100)]], category: [course.category, [Validators.required]], lessons: this.formBuilder.array(this.retrieveLessons(course), Validators.required) }); -
Is the data validated on the server side? @PostMapping @ResponseStatus(code
= HttpStatus.CREATED) public CourseDTO create(@RequestBody CourseRequestDTO course) { return courseService.create(course); } public record CourseRequestDTO( String name, String category, List<LessonDTO> lessons ) {} -
Same validations on the front-end and API this.form = this.formBuilder.group({
name: [ course.name, [Validators.required, Validators.minLength(5), Validators.maxLength(100)]], category: [course.category, [Validators.required]], lessons: this.formBuilder.array( this.retrieveLessons(course), Validators.required) }); public record CourseRequestDTO( @NotBlank @NotNull @Length(min = 5, max = 100) String name, @NotBlank @NotNull @ValueOfEnum(enumClass = Category.class) String category, @NotNull @NotEmpty @Valid List<LessonDTO> lessons) {} -
Validate type, length, format, and range, and enforce limits @NotBlank
@NotNull @Length(min = 5, max = 100) @Column(length = 100, nullable = false) private String name; @NotNull @Column(length = 10, nullable = false) @Convert(converter = CategoryConverter.class) private Category category; @NotNull @Column(length = 8, nullable = false) @Convert(converter = StatusConverter.class) private Status status = Status.ACTIVE; @NotNull @NotEmpty @Valid @OneToMany(mappedBy = "course" …) private Set<Lesson> lessons = new HashSet<>(); -
Validate Strings! @NotBlank @NotNull @Length(min = 5, max = 100)
@Column(length = 100, nullable = false) private String name; POST http://127.0.0.1:8080/api/courses content-type: application/json { "name": "!@#$%ˆ&*();" } Is this a valid name? -
Prefer allowed lists @Pattern(regexp = "^[^!@#$%^&*()_+]+$") private String name; @Pattern(regexp
= "^[a-zA-Z0-9-_() ]+$") private String name; Prefer allowed list -
Secure all the layers! @Validated public class CourseController { public
CoursePageDTO findAll(@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") @Max(1000) int pageSize) { } public List<CourseDTO> findByName(@RequestParam @NotNull @NotBlank @Pattern(regexp = "^[a-zA-Z0-9-_() ]+$") String name) { } public CourseDTO findById(@PathVariable @Positive @NotNull Long id) { } public CourseDTO create(@RequestBody @Valid CourseRequestDTO course) { } public CourseDTO update(@PathVariable @Positive @NotNull Long id, @RequestBody @Valid CourseRequestDTO course) { } } Sanitize or use regex -
Secure all the layers! @Validated public class CourseService { public
CoursePageDTO findAll(@PositiveOrZero int page, @Positive @Max(1000) int pageSize) {} public List<CourseDTO> findByName(@NotNull @NotBlank @Pattern(regexp = "^[a-zA-Z0-9-_() ]+$") String name) {} //... } -
Secure all the layers! public class Course { @NotBlank @NotNull
@Length(min = 5, max = 100) @Column(length = 100, nullable = false) @Pattern(regexp = "^[a-zA-Z0-9-_() ]+$") private String name; @NotNull @Column(length = 10, nullable = false) @Convert(converter = CategoryConverter.class) private Category category; @NotNull @Column(length = 8, nullable = false) @Convert(converter = StatusConverter.class) private Status status = Status.ACTIVE; } -
SQL Injection - dynamic SQL String query = "from Course
where name = " + courseName; Query q = sessionFactory.getCurrentSession().createQuery(query); List<Course> courses = q.list(); String query = "from Course where name = :name"; Query q = sessionFactory.getCurrentSession().createQuery(query); q.setParameter("name", userName); List<Course> courses = q.list(); -
Extension and Type Validation # Using Apache Commons IO. Returns
"pdf" import org.apache.commons.io.FilenameUtils; String extension = FilenameUtils.getExtension("test.pdf"); Don't trust the Content-Type header, always go to the data source! Set a filename length limit and restrict the allowed characters if possible -
Path Traversal Vulnerability @PostMapping("/uploadThumbnail") public void uploadThumbnail (@RequestParam MultipartFile file)
throws IOException { var name = file.getOriginalFilename().replace(" ", "_"); var fileNameAndPath = Paths.get(STORAGE_DIRECTORY, name); Files.write(fileNameAndPath, file.getBytes()); } What if fileName gets updated to images/profiles/../../../../../../../etc/passwd? -
Apache Commons IO import org.apache.commons.io.FilenameUtils; @PostMapping("/uploadThumbnail") public void uploadThumbnail (@RequestParam
MultipartFile file) throws IOException { var name = file.getOriginalFilename().replace(" ", "_"); var fileNameAndPath = Paths.get(STORAGE_DIRECTORY, name); String normalizedFileName = FilenameUtils.normalize(fileNameAndPath); Files.write(normalizedFileName, file.getBytes()); } -
Apache Tika public void uploadThumbnail(@RequestParam MultipartFile file) throws IOException {
try (InputStream stream = TikaInputStream.get(file.getInputStream())) { AutoDetectParser parser = new AutoDetectParser(); BodyContentHandler handler = new BodyContentHandler(); Metadata metadata = new Metadata(); parser.parse(stream, handler, metadata); String normalizedFileName = metadata.get(Metadata.RESOURCE_NAME_KEY); Files.write(normalizedFileName, file.getBytes()); } catch (Exception e) { throw new IOException("Failed to normalize file name", e); } } -
File Content Validation • Scan the file for viruses •
Check for macros and formulas in CSVs and Excel spreadsheets • Check of embedded objects in documents -
Do not expose the stack trace with Exceptions server.error.include-stacktrace=never Log
the stack trace, always return a generic message that is helpful -
Careful with what you are logging Authentication Credentials Personally Identifiable
Information (PII) Financial Information Health and Medical Information Confidential Business Data Legal and Compliance Data Personal Communications Biometric Data -
Remove sensitive data from toString class User { private String
userid; private String password; public String toString(){ return "Userid: " + userid; } } logger.info("User logged in: " + user.toString()); -
Use IDs, vault tokens or masked data instead logger.info("Payment submitted
for credit card number: " + creditCard.getNumber()); logger.info("Payment submitted for credit card id: " + creditCard.getId()); -
Rate limit for APIs • Spring AOP for simple solutions
• Bucket4j Rate Limiting Library • Redis Also helps preventing data mining in case one API has vulnerabilities -
Unit Tests are not enough! Test the exception cases! @ParameterizedTest(name
= "should return bad request when creating a course with invalid data: {0}") @MethodSource("com.loiane.course.TestData#createInvalidCoursesDTO") void testCreateInvalid(CourseRequestDTO course) { assertThrows(ConstraintViolationException.class, () -> this.courseService.create(course)); then(courseRepository).shouldHaveNoInteractions(); } public static List<CourseRequestDTO> createInvalidCoursesDTO() { return List.of( new CourseRequestDTO(null, null, createLessonsDTO()), new CourseRequestDTO(VALID_CATEGORY, null, createLessonsDTO()), new CourseRequestDTO(VALID_CATEGORY, "", createLessonsDTO()), new CourseRequestDTO(VALID_CATEGORY, INVALID_COURSE_NAME, createLessonsDTO()), new CourseRequestDTO(VALID_CATEGORY, LOREN_IPSUM, createLessonsDTO()), new CourseRequestDTO(null, VALID_NAME, createLessonsDTO()), // more invalid data } -
Educate the team • Have internal demos of the product
• Ask questions how this was implemented • Give feedback • Make sure security is part of requirements and user stories • Security should not be treated as technical debt • Prioritize the vulnerabilities often found within your organization -
Simple security checklist All endpoints are authenticated and authorized? Can
user modify the data / record? Input is validated and sanitized? Validation in all layers? Entity, Service, Controller? Validation done against data source instead of request? File uploads validated: extension, fileName, formulas/macros/objects, scanned? No passwords / keys / confidential data exposed? Error handling not exposing the stack trace?