Skip to content

Commit a5da2f7

Browse files
authored
Merge pull request #188 from lcnicolau/htmx-csrf-token
Add support for automatic CSRF token injection
2 parents 114fe75 + 4512230 commit a5da2f7

8 files changed

Lines changed: 310 additions & 10 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,14 @@ You can use multiple values like this:
383383
<div hx:vals="${ {id: user.id, groupId: group.id } }"></div>
384384
```
385385

386+
#### Automatic CSRF token injection
387+
388+
A Cross-Site Request Forgery (CSRF) attack tricks an authenticated user into performing unintended state-changing actions in a web application.
389+
390+
By default, Spring Security provides built-in protection against CSRF attacks on unsafe HTTP methods, while Thymeleaf automatically includes the required CSRF token as a hidden field in forms using `th:action`.
391+
392+
The library extends this support to htmx by automatically injecting the CSRF token into the request headers through the `hx-headers` attribute of elements using `hx:post`, `hx:put`, `hx:patch`, or `hx:delete`, even if the element is not part of a form.
393+
386394
## Articles
387395

388396
Links to articles and blog posts about this library:

htmx-spring-boot-thymeleaf/pom.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
<artifactId>spring-boot-starter-webmvc</artifactId>
2929
<optional>true</optional>
3030
</dependency>
31+
<dependency>
32+
<groupId>org.springframework.boot</groupId>
33+
<artifactId>spring-boot-starter-security</artifactId>
34+
<optional>true</optional>
35+
</dependency>
3136
<dependency>
3237
<groupId>org.springframework.boot</groupId>
3338
<artifactId>spring-boot-starter-thymeleaf</artifactId>
@@ -43,6 +48,11 @@
4348
<artifactId>spring-boot-starter-webmvc-test</artifactId>
4449
<scope>test</scope>
4550
</dependency>
51+
<dependency>
52+
<groupId>org.springframework.boot</groupId>
53+
<artifactId>spring-boot-starter-security-test</artifactId>
54+
<scope>test</scope>
55+
</dependency>
4656
</dependencies>
4757

4858
</project>

htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxAttributeProcessor.java

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
import tools.jackson.core.JacksonException;
1818
import tools.jackson.databind.ObjectMapper;
1919

20-
import java.util.LinkedHashMap;
20+
import java.util.Map;
2121

2222
public class HtmxAttributeProcessor extends AbstractStandardExpressionAttributeTagProcessor
2323
implements IAttributeDefinitionsAware {
2424

2525
public static final int ATTR_PRECEDENCE = 1000;
2626
private final String attrName;
27-
private final ObjectMapper mapper;
27+
protected final ObjectMapper mapper;
2828

2929
private static final TemplateMode TEMPLATE_MODE = TemplateMode.HTML;
3030

@@ -33,7 +33,14 @@ public class HtmxAttributeProcessor extends AbstractStandardExpressionAttributeT
3333
public HtmxAttributeProcessor(String dialectPrefix,
3434
String attrName,
3535
ObjectMapper mapper) {
36-
super(TEMPLATE_MODE, dialectPrefix, attrName, ATTR_PRECEDENCE, false, true);
36+
this(dialectPrefix, attrName, ATTR_PRECEDENCE, mapper);
37+
}
38+
39+
public HtmxAttributeProcessor(String dialectPrefix,
40+
String attrName,
41+
int precedence,
42+
ObjectMapper mapper) {
43+
super(TEMPLATE_MODE, dialectPrefix, attrName, precedence, false, true);
3744
this.attrName = attrName;
3845
this.mapper = mapper;
3946
}
@@ -46,7 +53,7 @@ public void setAttributeDefinitions(final AttributeDefinitions attributeDefiniti
4653
}
4754

4855
@Override
49-
protected final void doProcess(
56+
protected void doProcess(
5057
final ITemplateContext context,
5158
final IProcessableElementTag tag,
5259
final AttributeName attributeName,
@@ -57,7 +64,7 @@ protected final void doProcess(
5764
structureHandler.removeAttribute(attributeName);
5865
} else {
5966
String expressionResultString;
60-
if (expressionResult instanceof LinkedHashMap) {
67+
if (expressionResult instanceof Map) {
6168
try {
6269
expressionResultString = this.mapper.writeValueAsString(expressionResult);
6370
} catch (JacksonException e) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf;
2+
3+
import org.springframework.security.web.csrf.CsrfToken;
4+
import org.thymeleaf.context.ITemplateContext;
5+
import org.thymeleaf.engine.AttributeName;
6+
import org.thymeleaf.exceptions.TemplateProcessingException;
7+
import org.thymeleaf.model.IProcessableElementTag;
8+
import org.thymeleaf.processor.element.IElementTagStructureHandler;
9+
import org.unbescape.html.HtmlEscape;
10+
import tools.jackson.core.JacksonException;
11+
import tools.jackson.core.type.TypeReference;
12+
import tools.jackson.databind.ObjectMapper;
13+
14+
import java.util.HashMap;
15+
import java.util.Map;
16+
17+
/**
18+
* Thymeleaf processor for seamless integration of htmx with Spring Boot applications using CSRF protection.
19+
* <p>
20+
* Automatically injects the current Spring Security {@link CsrfToken} into htmx request headers.
21+
* It obtains the CSRF token from the Thymeleaf context (via the {@code _csrf} variable)
22+
* and merges it into the {@code hx-headers} attribute, while preserving any existing values.
23+
* <p>
24+
* If no CSRF token is available, the processor performs no action.
25+
* <p>
26+
* This enables htmx-triggered requests to include the CSRF token automatically,
27+
* eliminating the need for manual token handling in templates.
28+
* <p>
29+
* Example:
30+
* <pre>{@code
31+
* <a hx:post="@{/logout}">Log out</a>
32+
* }</pre>
33+
* <p>
34+
* After processing, will render as:
35+
* <pre>{@code
36+
* <a hx-post="/logout"
37+
* hx-headers="{&quot;X-CSRF-TOKEN&quot;:&quot;abc123&quot;}">Log out</a>
38+
* }</pre>
39+
* ("abc123" represents the real CSRF token that Spring Security provides at runtime)
40+
*
41+
* @author LC Nicolau
42+
* @see <a href="https://htmx.org/docs/#csrf-prevention">CSRF Prevention</a>
43+
* @see <a href="https://htmx.org/attributes/hx-headers/">hx-headers Attribute Reference</a>
44+
* @since 5.1.0
45+
*/
46+
public class HtmxCsrfAttributeProcessor extends HtmxAttributeProcessor {
47+
48+
public HtmxCsrfAttributeProcessor(String dialectPrefix,
49+
String attrName,
50+
ObjectMapper mapper) {
51+
super(dialectPrefix, attrName, ATTR_PRECEDENCE + 1, mapper);
52+
}
53+
54+
@Override
55+
protected void doProcess(
56+
final ITemplateContext context,
57+
final IProcessableElementTag tag,
58+
final AttributeName attributeName,
59+
final String attributeValue,
60+
final Object expressionResult,
61+
final IElementTagStructureHandler structureHandler) {
62+
super.doProcess(context, tag, attributeName, attributeValue, expressionResult, structureHandler);
63+
64+
var token = (CsrfToken) context.getVariable("_csrf");
65+
if (token == null || expressionResult == null) {
66+
return;
67+
}
68+
var headers = this.getHeaders(tag);
69+
headers.put(token.getHeaderName(), token.getToken());
70+
try {
71+
var json = mapper.writeValueAsString(headers);
72+
var escaped = HtmlEscape.escapeHtml4Xml(json);
73+
structureHandler.setAttribute("hx-headers", escaped);
74+
} catch (JacksonException e) {
75+
throw new TemplateProcessingException("Exception writing map", tag.getTemplateName(), tag.getLine(), tag.getLine(), e);
76+
}
77+
}
78+
79+
protected Map<String, Object> getHeaders(IProcessableElementTag tag) {
80+
var current = tag.getAttributeValue("hx-headers");
81+
if (current == null || current.isBlank()) {
82+
return new HashMap<>();
83+
}
84+
try {
85+
var json = HtmlEscape.unescapeHtml(current);
86+
return this.mapper.readValue(json, new TypeReference<>() {
87+
});
88+
} catch (JacksonException e) {
89+
throw new TemplateProcessingException("Exception reading map", tag.getTemplateName(), tag.getLine(), tag.getLine(), e);
90+
}
91+
}
92+
93+
}

htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxDialect.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public Set<IProcessor> getProcessors(String dialectPrefix) {
2626

2727
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "boost", mapper));
2828
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "confirm", mapper));
29-
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "delete", mapper));
29+
htmxProcessors.add(new HtmxCsrfAttributeProcessor(dialectPrefix, "delete", mapper));
3030
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "disable", mapper));
3131
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "disinherit", mapper));
3232
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "encoding", mapper));
@@ -37,11 +37,11 @@ public Set<IProcessor> getProcessors(String dialectPrefix) {
3737
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "include", mapper));
3838
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "indicator", mapper));
3939
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "params", mapper));
40-
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "patch", mapper));
41-
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "post", mapper));
40+
htmxProcessors.add(new HtmxCsrfAttributeProcessor(dialectPrefix, "patch", mapper));
41+
htmxProcessors.add(new HtmxCsrfAttributeProcessor(dialectPrefix, "post", mapper));
4242
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "preserve", mapper));
4343
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "prompt", mapper));
44-
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "put", mapper));
44+
htmxProcessors.add(new HtmxCsrfAttributeProcessor(dialectPrefix, "put", mapper));
4545
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "push-url", mapper));
4646
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "request", mapper));
4747
htmxProcessors.add(new HtmxAttributeProcessor(dialectPrefix, "select", mapper));
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.beans.factory.annotation.Autowired;
5+
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.security.config.Customizer;
9+
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
10+
import org.springframework.security.test.context.support.WithMockUser;
11+
import org.springframework.security.web.SecurityFilterChain;
12+
import org.springframework.security.web.csrf.CsrfToken;
13+
import org.springframework.stereotype.Controller;
14+
import org.springframework.test.context.ContextConfiguration;
15+
import org.springframework.test.web.servlet.MockMvc;
16+
import org.springframework.test.web.servlet.MvcResult;
17+
import org.springframework.web.bind.annotation.GetMapping;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
21+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
22+
23+
/**
24+
* Verifies automatic CSRF token injection into elements using unsafe HTTP methods
25+
* ({@code hx:post}, {@code hx:put}, {@code hx:patch}, {@code hx:delete}).
26+
* <p>
27+
* Tests cover the following scenarios:
28+
* <ul>
29+
* <li>The CSRF token is automatically injected into the {@code hx-headers} attribute.</li>
30+
* <li>Correct merging when {@code hx-headers} already contains additional entries.</li>
31+
* <li>Proper behavior when CSRF protection is disabled or no token is present.</li>
32+
* </ul>
33+
*
34+
* @author LC Nicolau
35+
* @since 5.1.0
36+
*/
37+
@WebMvcTest(controllers = HtmxCsrfTest.TestController.class)
38+
@ContextConfiguration(classes = {HtmxCsrfTest.TestController.class, HtmxCsrfTest.SecurityConfig.class})
39+
@WithMockUser
40+
class HtmxCsrfTest {
41+
42+
@Autowired
43+
private MockMvc mockMvc;
44+
45+
@Test
46+
void testHxPostCsrf() throws Exception {
47+
MvcResult result = mockMvc.perform(get("/htmx-csrf"))
48+
.andExpect(status().isOk())
49+
.andReturn();
50+
51+
String html = result.getResponse().getContentAsString();
52+
assertThat(html)
53+
.containsPattern("hx-post-div.*hx-post=\"/foo\"")
54+
.containsPattern("hx-post-headers.*hx-post=\"/foo\"");
55+
56+
CsrfToken csrf = ((CsrfToken) result.getRequest().getAttribute("_csrf"));
57+
if (csrf == null) {
58+
assertThat(html)
59+
.doesNotContainPattern("hx-post-div.*hx-headers=")
60+
.doesNotContainPattern("hx-post-headers.*hx-headers=.*X-CSRF-TOKEN");
61+
} else {
62+
String token = csrf.getToken();
63+
assertThat(html)
64+
.containsPattern("hx-post-div.*hx-headers=\"\\{&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"")
65+
.containsPattern("hx-post-headers.*hx-headers=\"\\{&quot;someHeader&quot;:true,&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"");
66+
}
67+
}
68+
69+
@Test
70+
void testHxPutCsrf() throws Exception {
71+
MvcResult result = mockMvc.perform(get("/htmx-csrf"))
72+
.andExpect(status().isOk())
73+
.andReturn();
74+
75+
String html = result.getResponse().getContentAsString();
76+
assertThat(html)
77+
.containsPattern("hx-put-div.*hx-put=\"/foo\"")
78+
.containsPattern("hx-put-headers.*hx-put=\"/foo\"");
79+
80+
CsrfToken csrf = ((CsrfToken) result.getRequest().getAttribute("_csrf"));
81+
if (csrf == null) {
82+
assertThat(html)
83+
.doesNotContainPattern("hx-put-div.*hx-headers=")
84+
.doesNotContainPattern("hx-put-headers.*hx-headers=.*X-CSRF-TOKEN");
85+
} else {
86+
String token = csrf.getToken();
87+
assertThat(html)
88+
.containsPattern("hx-put-div.*hx-headers=\"\\{&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"")
89+
.containsPattern("hx-put-headers.*hx-headers=\"\\{&quot;someHeader&quot;:true,&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"");
90+
}
91+
}
92+
93+
@Test
94+
void testHxPatchCsrf() throws Exception {
95+
MvcResult result = mockMvc.perform(get("/htmx-csrf"))
96+
.andExpect(status().isOk())
97+
.andReturn();
98+
99+
String html = result.getResponse().getContentAsString();
100+
assertThat(html)
101+
.containsPattern("hx-patch-div.*hx-patch=\"/foo\"")
102+
.containsPattern("hx-patch-headers.*hx-patch=\"/foo\"");
103+
104+
CsrfToken csrf = ((CsrfToken) result.getRequest().getAttribute("_csrf"));
105+
if (csrf == null) {
106+
assertThat(html)
107+
.doesNotContainPattern("hx-patch-div.*hx-headers=")
108+
.doesNotContainPattern("hx-patch-headers.*hx-headers=.*X-CSRF-TOKEN");
109+
} else {
110+
String token = csrf.getToken();
111+
assertThat(html)
112+
.containsPattern("hx-patch-div.*hx-headers=\"\\{&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"")
113+
.containsPattern("hx-patch-headers.*hx-headers=\"\\{&quot;someHeader&quot;:true,&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"");
114+
}
115+
}
116+
117+
@Test
118+
void testHxDeleteCsrf() throws Exception {
119+
MvcResult result = mockMvc.perform(get("/htmx-csrf"))
120+
.andExpect(status().isOk())
121+
.andReturn();
122+
123+
String html = result.getResponse().getContentAsString();
124+
assertThat(html)
125+
.containsPattern("hx-delete-div.*hx-delete=\"/foo\"")
126+
.containsPattern("hx-delete-headers.*hx-delete=\"/foo\"");
127+
128+
CsrfToken csrf = ((CsrfToken) result.getRequest().getAttribute("_csrf"));
129+
if (csrf == null) {
130+
assertThat(html)
131+
.doesNotContainPattern("hx-delete-div.*hx-headers=")
132+
.doesNotContainPattern("hx-delete-headers.*hx-headers=.*X-CSRF-TOKEN");
133+
} else {
134+
String token = csrf.getToken();
135+
assertThat(html)
136+
.containsPattern("hx-delete-div.*hx-headers=\"\\{&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"")
137+
.containsPattern("hx-delete-headers.*hx-headers=\"\\{&quot;someHeader&quot;:true,&quot;X-CSRF-TOKEN&quot;:&quot;" + token + "&quot;}\"");
138+
}
139+
}
140+
141+
@Controller
142+
static class TestController {
143+
144+
@GetMapping("/htmx-csrf")
145+
public String csrf() {
146+
return "htmx-csrf";
147+
}
148+
149+
}
150+
151+
@Configuration
152+
static class SecurityConfig {
153+
154+
@Bean
155+
SecurityFilterChain securityFilterChain(HttpSecurity http) {
156+
return http.authorizeHttpRequests(config -> config
157+
.requestMatchers("/htmx-csrf").authenticated())
158+
.csrf(Customizer.withDefaults())
159+
.build();
160+
}
161+
162+
}
163+
164+
}

htmx-spring-boot-thymeleaf/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxDialectTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf;
22

3-
43
import org.junit.jupiter.api.Test;
54
import org.springframework.beans.factory.annotation.Autowired;
65
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
6+
import org.springframework.security.test.context.support.WithMockUser;
77
import org.springframework.test.context.ContextConfiguration;
88
import org.springframework.test.web.servlet.MockMvc;
99

@@ -14,6 +14,7 @@
1414

1515
@WebMvcTest(HtmxDialectTestController.class)
1616
@ContextConfiguration(classes = HtmxDialectTestController.class)
17+
@WithMockUser
1718
class HtmxDialectTest {
1819

1920
@Autowired

0 commit comments

Comments
 (0)