Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/main/java/http/request/Cookies.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package http.request;

import java.util.*;

public class Cookies {
private final Map<String, String> values;
private Cookies(Map<String, String> values) {
this.values = Collections.unmodifiableMap(values);
}

public static Cookies empty() {
return new Cookies(new HashMap<>());
}

public static Cookies parse(String rawHeader) {
if (rawHeader == null || rawHeader.isBlank()) return empty();

Map<String, String> map = new LinkedHashMap<>();
String[] parts = rawHeader.split(";");
for (String part : parts) {
String token = part.trim();
if (token.isEmpty()) continue;

String[] kv = token.split("=", 2);
String name = kv[0].trim();
String value = kv.length == 2 ? kv[1].trim() : "";

if (!name.isEmpty()) {
map.put(name, value); // 동일 name은 마지막 값으로 덮어씀(브라우저/서버 실무 관행)
}
Comment on lines 17 to 31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XSS 위험: 요청 쿠키 값이 검증되지 않습니다.

  • parse() 메서드가 쿠키 헤더 값을 그대로 분석하여 저장합니다.
  • RFC 6265에서 쿠키 값은 특정 문자만 허용되지만, 여기서는 검증이 없습니다.
  • 악의적인 값이 values 맵에 저장되어 나중에 사용될 때 문제가 될 수 있습니다.

해결책: 쿠키 값의 유효성을 검증하거나, 사용 시점에 필요한 인코딩/디코딩을 적용하세요.

}
return new Cookies(map);
}

public Optional<String> get(String name) {
return Optional.ofNullable(values.get(name));
}

public boolean contains(String name) {
return values.containsKey(name);
}

public Map<String, String> asMap() {
return values;
}
}
48 changes: 30 additions & 18 deletions src/main/java/http/request/HttpRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public class HttpRequest {
private final HttpMethod method;
private final Map<String, String> headers; //TODO: Map<String, List<String>> 타입으로 변경
private Cookies cookies;
private String httpVersion;
private final URI uri;
private String contentType;
Expand All @@ -30,28 +32,52 @@ private HttpRequest (HttpMethod method,
this.headers = new HashMap<>();
}

public static HttpRequest from(String requestLine){
String[] parts = requestLine.split(" ");
try {
return new HttpRequest(
HttpMethod.valueOf(parts[0].strip().toUpperCase()),
parts[1].strip(),
parts[2].strip());
} catch (IllegalArgumentException e) {
throw new ServiceException(ErrorCode.METHOD_NOT_ALLOWED);
} catch (NullPointerException e){
throw new ErrorException("Http method error");
}
}

Comment on lines 41 to 49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NPE 위험: this.cookies가 초기화되지 않은 상태에서 getCookieValue() 메서드가 호출되면 NullPointerException이 발생합니다.

  • HttpRequest 생성자에서 cookies를 초기화하지 않습니다 (기본값이 null).
  • setHeader("Cookie", value)가 호출되지 않으면 cookies는 null로 유지됩니다.
  • getCookieValue(String key) 호출 시 this.cookies.get(key)에서 NPE 발생.

해결책: 생성자에서 this.cookies = Cookies.empty();로 초기화하세요.

Comment on lines 45 to 49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NPE 위험: getCookies() 메서드도 null을 반환할 수 있습니다.

  • getCookies()에서 this.cookies가 null일 수 있습니다.
  • StaticViewRenderer 라인 33에서 handlerResponse.getCookies().forEach(...)가 호출되는데, getCookies()가 null이면 NPE 발생.

해결책: Cookies.empty()를 기본값으로 초기화하세요.

public byte[] getBody() {
return body;
}

public String getContentType() {
return contentType;
}
public String getHeader(String key){
return headers.get(key.toLowerCase());
}

public void setHeader(String key, String value){
headers.put(key.toLowerCase(), value);
if (key.equalsIgnoreCase("Content-Type")) {
this.contentType = value;
} else if (key.equalsIgnoreCase("Cookie")) {
this.cookies = Cookies.parse(value);
}
}

public String getHeader(String key){
return headers.get(key.toLowerCase());
}

public List<String> getHeaders(){
return headers.keySet().stream().toList();
}

public Cookies getCookies(){
return this.cookies;
}

public Optional<String> getCookieValue(String key){
return this.cookies.get(key);
}

public String getPath(){
return uri.getPath();
}
Expand All @@ -72,20 +98,6 @@ public InetAddress getRequestAddress() {
return requestAddress;
}

public static HttpRequest from(String requestLine){
String[] parts = requestLine.split(" ");
try {
return new HttpRequest(
HttpMethod.valueOf(parts[0].strip().toUpperCase()),
parts[1].strip(),
parts[2].strip());
} catch (IllegalArgumentException e) {
throw new ServiceException(ErrorCode.METHOD_NOT_ALLOWED);
} catch (NullPointerException e){
throw new ErrorException("Http method error");
}
}

public void setBody(byte[] body){
this.body = body;
}
Expand Down
112 changes: 112 additions & 0 deletions src/main/java/http/response/CookieBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package http.response;

import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;

public class CookieBuilder implements ResponseCookie{
private final String name;
private final String value;

private String path;
private String domain;
private Long maxAge; // seconds
private ZonedDateTime expires;
private boolean httpOnly;
private boolean secure;
private SameSite sameSite;

public enum SameSite { STRICT, LAX, NONE }

private CookieBuilder(String name, String value) {
if (name == null || name.isBlank()) throw new IllegalArgumentException("cookie name required");
this.name = name;
this.value = value == null ? "" : value;
}

public static CookieBuilder of(String name, String value) {
return new CookieBuilder(name, value);
}

public static CookieBuilder delete(String name) {
return CookieBuilder.of(name, "")
.path("/") // 보통 명시
.maxAge(0);
}

Comment on lines +33 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

논리 오류: SameSite=None일 때 Secure 플래그가 필수인데, 이를 강제하지 않습니다.

  • HTTP 명세상 SameSite=None은 반드시 Secure 쿠키와 함께 사용되어야 합니다.
  • 현재 코드는 sameSite(SameSite.NONE).toHeaderValue()를 호출해도 secure가 false이면 검증이 없습니다.

해결책: toHeaderValue() 메서드 시작 부분에서 sameSite == SameSite.NONE && !secure일 때 예외를 던지거나, 자동으로 secure = true로 설정하세요.

public CookieBuilder path(String path) {
this.path = path;
return this;
}

public CookieBuilder domain(String domain) {
this.domain = domain;
return this;
}

public CookieBuilder maxAge(long seconds) {
if (seconds < 0) throw new IllegalArgumentException("maxAge must be >= 0");
this.maxAge = seconds;
return this;
}

public CookieBuilder maxAge(Duration duration) {
Objects.requireNonNull(duration);
return maxAge(duration.getSeconds());
}

public CookieBuilder expires(ZonedDateTime expires) {
this.expires = expires;
return this;
}

public CookieBuilder httpOnly() {
this.httpOnly = true;
return this;
}

public CookieBuilder secure() {
this.secure = true;
return this;
}

public CookieBuilder sameSite(SameSite sameSite) {
this.sameSite = sameSite;
return this;
}

@Override
public String toHeaderValue() {
StringBuilder sb = new StringBuilder();
sb.append(name).append("=").append(value);

if (domain != null && !domain.isBlank()) sb.append("; Domain=").append(domain);
if (path != null && !path.isBlank()) sb.append("; Path=").append(path);
Comment on lines +77 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XSS/Injection 위험: 쿠키 값이 검증되지 않은 채로 직접 HTTP 헤더에 추가됩니다.

  • toHeaderValue()에서 name, value, domain, path 등을 그대로 StringBuilder에 추가합니다.
  • 악의적인 값(예: 개행, 세미콜론)이 포함되면 HTTP 응답 헤더 주입 공격(Response Splitting)이 가능합니다.
  • 예: path = "/\r\nSet-Cookie: admin=true"와 같은 입력으로 추가 쿠키 주입 가능.

해결책: 쿠키 값을 검증하거나 인코딩하세요. RFC 6265 표준에 따라 유효한 문자인지 확인하고, 필요시 URL 인코딩을 적용하세요.


if (maxAge != null) sb.append("; Max-Age=").append(maxAge);

if (expires != null) {
sb.append("; Expires=").append(DateTimeFormatter.RFC_1123_DATE_TIME.format(expires));
}

if (secure) sb.append("; Secure");
if (httpOnly) sb.append("; HttpOnly");

if (sameSite != null) {
sb.append("; SameSite=").append(
switch (sameSite) {
case STRICT -> "Strict";
case LAX -> "Lax";
case NONE -> "None";
}
);
}
return sb.toString();
}

@Override
public String toString() {
return toHeaderValue();
}
}
5 changes: 5 additions & 0 deletions src/main/java/http/response/ResponseCookie.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package http.response;

public interface ResponseCookie {
String toHeaderValue();
}
18 changes: 18 additions & 0 deletions src/main/java/http/response/SimpleCookieBuilder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package http.response;

public class SimpleCookieBuilder implements ResponseCookie{
private final String cookieString;

private SimpleCookieBuilder(String str){
this.cookieString = str;
}

public static SimpleCookieBuilder of(String str){
return new SimpleCookieBuilder(str);
}

@Override
public String toHeaderValue() {
return cookieString;
}
}
1 change: 1 addition & 0 deletions src/main/java/web/renderer/StaticViewRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public HttpResponse handle(HandlerResponse handlerResponse) {

HttpResponse httpResponse = HttpResponse.of(handlerResponse.getStatus());
httpResponse.setBody(file, body);
handlerResponse.getCookies().forEach(cookie->httpResponse.addHeader("Set-Cookie", cookie));
return httpResponse;
Comment on lines 32 to 34
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NPE 위험: getCookies()가 null을 반환할 수 있습니다.

  • HandlerResponse.getCookies()는 초기화된 List를 반환해야 하지만, 부모 클래스에서 cookies가 제대로 초기화되지 않으면 문제가 발생할 수 있습니다.
  • 현재 코드는 HandlerResponse 생성자에서 cookies = new ArrayList<>()로 초기화하므로 안전하지만, 다른 곳에서 null로 덮어쓸 가능성이 있습니다.

해결책: defensive programming으로 handlerResponse.getCookies() 결과를 체크하거나, getCookies()가 항상 empty list를 반환하도록 보장하세요.


} catch (IOException e) {
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/web/response/HandlerResponse.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
package web.response;

import http.HttpStatus;
import http.response.CookieBuilder;
import http.response.ResponseCookie;

import java.util.ArrayList;
import java.util.List;

public abstract class HandlerResponse {
protected final HttpStatus status;
protected final List<ResponseCookie> cookies;


protected HandlerResponse(HttpStatus status) {
this.status = status;
this.cookies = new ArrayList<>();
}

public HttpStatus getStatus(){
return status;
}

public void setCookie(CookieBuilder cookie){
this.cookies.add(cookie);
Comment on lines 20 to 24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

타입 불일치 위험: setCookie() 메서드의 매개변수 타입이 CookieBuilder로 고정되어 있습니다.

  • 인터페이스 ResponseCookie가 정의되어 있지만, setCookie()CookieBuilder 타입만 수용합니다.
  • SimpleCookieBuilderResponseCookie를 구현하지만, setCookie()로 전달할 수 없습니다.

해결책: 메서드를 public void setCookie(ResponseCookie cookie)로 변경하세요. 이렇게 하면 모든 구현체를 수용할 수 있습니다.

}

public List<String> getCookies(){
return this.cookies.stream().map(ResponseCookie::toHeaderValue).toList();
}
}