Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Commit 80314f2

Browse files
authored
feat: improve security and prevent xss attacks (#30)
* feat: improve security and prevent xss attacks * chore: fix frontend test file name location * fix: frontend test case * ci: add deployment connection
1 parent c9c414d commit 80314f2

21 files changed

Lines changed: 219 additions & 19 deletions

File tree

.github/workflows/cd-pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
jobs:
1010
terraform-plan-stg:
1111
name: Plan changs to staging
12+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
1213
runs-on: ubuntu-latest
1314
outputs:
1415
no_changes: ${{ steps.check-changes.outputs.no_changes }}

.github/workflows/ci-pr-backend.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ jobs:
5151
needs: ["run-checks"]
5252
permissions:
5353
contents: read
54+
deployments: write
5455
packages: write
5556

5657
steps:
@@ -100,3 +101,29 @@ jobs:
100101
docker tag ghcr.io/${{ steps.repo.outputs.name }}/api:latest ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
101102
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:candidate
102103
docker push ghcr.io/${{ steps.repo.outputs.name }}/api:pr-${{ github.event.pull_request.number }}
104+
105+
- name: Create GitHub deployment for staging
106+
if: ${{ github.event_name == 'pull_request' }}
107+
uses: actions/github-script@v6
108+
with:
109+
script: |
110+
const ref = context.payload.pull_request.head.sha;
111+
const env = 'staging';
112+
const resp = await github.rest.repos.createDeployment({
113+
owner: context.repo.owner,
114+
repo: context.repo.repo,
115+
ref,
116+
required_contexts: [],
117+
environment: env,
118+
description: `PR #${context.payload.pull_request.number} preview deployment`,
119+
transient_environment: true,
120+
auto_merge: false
121+
});
122+
// create a deployment status pointing to the staging URL
123+
await github.rest.repos.createDeploymentStatus({
124+
owner: context.repo.owner,
125+
repo: context.repo.repo,
126+
deployment_id: resp.data.id,
127+
state: 'success',
128+
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
129+
});

.github/workflows/ci-pr-frontend.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jobs:
6060
permissions:
6161
contents: write
6262
packages: write
63+
deployments: write
6364

6465
steps:
6566
- name: Checkout code
@@ -105,3 +106,29 @@ jobs:
105106
cache-to: type=gha,mode=max
106107
build-args: |
107108
VITE_BUILD=${{ steps.version.outputs.tag }}
109+
110+
- name: Create GitHub deployment for staging
111+
if: ${{ github.event_name == 'pull_request' }}
112+
uses: actions/github-script@v6
113+
with:
114+
script: |
115+
const ref = context.payload.pull_request.head.sha;
116+
const env = 'staging';
117+
const resp = await github.rest.repos.createDeployment({
118+
owner: context.repo.owner,
119+
repo: context.repo.repo,
120+
ref,
121+
required_contexts: [],
122+
environment: env,
123+
description: `PR #${context.payload.pull_request.number} preview deployment`,
124+
transient_environment: true,
125+
auto_merge: false
126+
});
127+
// create a deployment status pointing to the staging URL
128+
await github.rest.repos.createDeploymentStatus({
129+
owner: context.repo.owner,
130+
repo: context.repo.repo,
131+
deployment_id: resp.data.id,
132+
state: 'success',
133+
environment_url: 'https://tasknote-stg.darkroasted.vps-kinghost.net'
134+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { isSafeUrl } from '../../utils/UrlUtils';
3+
4+
describe('UrlUtils', () => {
5+
it('should allow http:// URLs', () => {
6+
expect(isSafeUrl('http://example.com')).toBe(true);
7+
});
8+
9+
it('should allow https:// URLs', () => {
10+
expect(isSafeUrl('https://example.com')).toBe(true);
11+
});
12+
13+
it('should allow # URLs', () => {
14+
expect(isSafeUrl('#section')).toBe(true);
15+
});
16+
17+
it('should disallow javascript: URLs', () => {
18+
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
19+
});
20+
21+
it('should disallow data: URLs', () => {
22+
expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
23+
});
24+
25+
it('should disallow empty or null URLs', () => {
26+
expect(isSafeUrl('')).toBe(false);
27+
expect(isSafeUrl(null)).toBe(false);
28+
expect(isSafeUrl(undefined)).toBe(false);
29+
});
30+
31+
it('should be case insensitive for protocol', () => {
32+
expect(isSafeUrl('HTTP://example.com')).toBe(true);
33+
expect(isSafeUrl('HTTPS://example.com')).toBe(true);
34+
});
35+
});

client/src/components/NoteTitle/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
3+
import { isSafeUrl } from '../../utils/UrlUtils';
34

45
interface Props {
56
readonly title: string;
@@ -18,8 +19,8 @@ function NoteTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
1819
<span className="task-title-icon">
1920
<span className="poppins-semibold">
2021
{props.title}
21-
{props.noteUrl && props.noteUrl.length > 0 && (
22-
<a href={props.noteUrl} target="_blank" rel="noreferrer" className="task-note-external-link">
22+
{isSafeUrl(props.noteUrl) && (
23+
<a href={props.noteUrl!} target="_blank" rel="noreferrer" className="task-note-external-link">
2324
<img src={ExternalLinkIcon} width={20} alt="external link" />
2425
</a>
2526
)}

client/src/components/TaskTitle/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
3+
import { isSafeUrl } from '../../utils/UrlUtils';
34
import './style.css';
45

56
interface Props {
@@ -24,7 +25,7 @@ function TaskTitle(props: React.PropsWithChildren<Props>): React.ReactNode {
2425
data-testid={`task-title-text-${props.title}`}
2526
>
2627
{props.title}
27-
{props.taskUrl && props.taskUrl.length > 0 && (
28+
{props.taskUrl && props.taskUrl.length > 0 && isSafeUrl(props.taskUrl[0]) && (
2829
<a href={props.taskUrl[0]} target="_blank" rel="noreferrer" className="task-note-external-link">
2930
<img src={ExternalLinkIcon} width={20} alt="external link" />
3031
</a>

client/src/utils/UrlUtils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Validates if a URL is safe to be used in an <a> tag.
3+
* Only allows http, https, and # (for internal links/placeholders).
4+
*
5+
* @param {string | null | undefined} url The URL to validate.
6+
* @returns {boolean} True if the URL is safe, false otherwise.
7+
*/
8+
export function isSafeUrl(url: string | null | undefined): boolean {
9+
if (!url) {
10+
return false;
11+
}
12+
const safeProtocolRegex = /^(https?:\/\/|#)/i;
13+
return safeProtocolRegex.test(url);
14+
}

client/src/views/SharedNote/index.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
66
import { NoteResponse } from '../../types/NoteResponse';
77
import api from '../../api-service/api';
88
import ApiConfig from '../../api-service/apiConfig';
9+
import { isSafeUrl } from '../../utils/UrlUtils';
910

1011
/**
1112
* SharedNote component for displaying a publicly shared note.
@@ -80,9 +81,9 @@ function SharedNote(): React.ReactNode {
8081
</Card.Header>
8182
<Card.Body>
8283
<Card.Title>{note.title}</Card.Title>
83-
{note.url && (
84+
{isSafeUrl(note.url) && (
8485
<p>
85-
<a href={note.url} target="_blank" rel="noopener noreferrer">
86+
<a href={note.url!} target="_blank" rel="noopener noreferrer">
8687
{note.url}
8788
</a>
8889
</p>

server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ public class UserEntity implements UserDetails {
5555
@Column(name = "lang", nullable = true, length = 6)
5656
private String lang;
5757

58+
@Column(name = "last_password_change", nullable = false)
59+
private LocalDateTime lastPasswordChange;
60+
5861
@Override
5962
public Collection<? extends GrantedAuthority> getAuthorities() {
6063
return List.of();
@@ -182,4 +185,12 @@ public String getLang() {
182185
public void setLang(String lang) {
183186
this.lang = lang;
184187
}
188+
189+
public LocalDateTime getLastPasswordChange() {
190+
return lastPasswordChange;
191+
}
192+
193+
public void setLastPasswordChange(LocalDateTime lastPasswordChange) {
194+
this.lastPasswordChange = lastPasswordChange;
195+
}
185196
}
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
package br.com.tasknoteapp.server.request;
22

3+
import jakarta.validation.constraints.Pattern;
4+
35
/** This record represents a note patch payload. */
4-
public record NotePatchRequest(String title, String description, String url, String tag) {}
6+
public record NotePatchRequest(
7+
String title,
8+
String description,
9+
@Pattern(
10+
regexp = "^(https?://.*|#.*)?$",
11+
message = "URL must start with http://, https:// or #")
12+
String url,
13+
String tag) {}

0 commit comments

Comments
 (0)