Skip to content

Commit 5d852d2

Browse files
authored
Add test for "fetch redirect", add CSS value validation for external render (#37207) (#37216)
Backport #37207
1 parent 2aca966 commit 5d852d2

7 files changed

Lines changed: 107 additions & 7 deletions

File tree

routers/common/redirect.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ func FetchRedirectDelegate(resp http.ResponseWriter, req *http.Request) {
1616
// 2. when use "window.reload()", the hash is not respected, the newly loaded page won't scroll to the hash target.
1717
// The typical page is "issue comment" page. The backend responds "/owner/repo/issues/1#comment-2",
1818
// then frontend needs this delegate to redirect to the new location with hash correctly.
19-
redirect := req.PostFormValue("redirect")
20-
if !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
21-
resp.WriteHeader(http.StatusBadRequest)
19+
redirect := req.FormValue("redirect")
20+
if req.Method != http.MethodPost || !httplib.IsCurrentGiteaSiteURL(req.Context(), redirect) {
21+
http.Error(resp, "Bad Request", http.StatusBadRequest)
2222
return
2323
}
24-
resp.Header().Add("Location", redirect)
24+
// no OpenRedirect, the "redirect" is validated by "IsCurrentGiteaSiteURL" above
25+
resp.Header().Set("Location", redirect)
2526
resp.WriteHeader(http.StatusSeeOther)
2627
}

routers/common/redirect_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright 2026 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package common
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"net/url"
10+
"strings"
11+
"testing"
12+
13+
"code.gitea.io/gitea/modules/setting"
14+
"code.gitea.io/gitea/modules/test"
15+
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
func TestFetchRedirectDelegate(t *testing.T) {
20+
defer test.MockVariableValue(&setting.AppURL, "https://gitea/")()
21+
22+
cases := []struct {
23+
method string
24+
input string
25+
status int
26+
}{
27+
{method: "POST", input: "/foo?k=v", status: http.StatusSeeOther},
28+
{method: "GET", input: "/foo?k=v", status: http.StatusBadRequest},
29+
{method: "POST", input: `\/foo?k=v`, status: http.StatusBadRequest},
30+
{method: "POST", input: `\\/foo?k=v`, status: http.StatusBadRequest},
31+
{method: "POST", input: "https://gitea/xxx", status: http.StatusSeeOther},
32+
{method: "POST", input: "https://other/xxx", status: http.StatusBadRequest},
33+
}
34+
for _, c := range cases {
35+
t.Run(c.method+" "+c.input, func(t *testing.T) {
36+
resp := httptest.NewRecorder()
37+
req := httptest.NewRequest(c.method, "/?redirect="+url.QueryEscape(c.input), nil)
38+
FetchRedirectDelegate(resp, req)
39+
assert.Equal(t, c.status, resp.Code)
40+
if c.status == http.StatusSeeOther {
41+
assert.Equal(t, c.input, resp.Header().Get("Location"))
42+
} else {
43+
assert.Empty(t, resp.Header().Get("Location"))
44+
assert.Equal(t, "Bad Request", strings.TrimSpace(resp.Body.String()))
45+
}
46+
})
47+
}
48+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import './external-render-helper.ts';
2+
3+
test('isValidCssColor', async () => {
4+
const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor;
5+
expect(isValidCssColor(null)).toBe(false);
6+
expect(isValidCssColor('')).toBe(false);
7+
8+
expect(isValidCssColor('#123')).toBe(true);
9+
expect(isValidCssColor('#1234')).toBe(true);
10+
expect(isValidCssColor('#abcabc')).toBe(true);
11+
expect(isValidCssColor('#abcabc12')).toBe(true);
12+
13+
expect(isValidCssColor('rgb(255 255 255)')).toBe(true);
14+
expect(isValidCssColor('rgb(0, 255, 255)')).toBe(true);
15+
16+
// examples from MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/rgb
17+
expect(isValidCssColor('rgb(255 255 255 / 50%)')).toBe(true);
18+
expect(isValidCssColor('rgb(from #123456 hwb(120deg 10% 20%) calc(g + 40) b / 0.5)')).toBe(true);
19+
20+
expect(isValidCssColor('#123 ; other')).toBe(false);
21+
expect(isValidCssColor('#123 : other')).toBe(false);
22+
expect(isValidCssColor('#rgb(0, 255, 255); other')).toBe(false);
23+
expect(isValidCssColor('#rgb(0, 255, 255)} other')).toBe(false);
24+
expect(isValidCssColor('url(other)')).toBe(false);
25+
});

web_src/js/external-render-helper.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px so
1515
1616
*/
1717

18+
// Check whether the user-provided color value is a valid CSS color format to avoid CSS injection.
19+
// Don't extract this function to a common module, because this file is an IIFE module for external render
20+
// and should not have any dependency to avoid potential conflicts.
21+
function isValidCssColor(s: string | null): boolean {
22+
if (!s) return false;
23+
// it should only be in format "#hex" or "rgb(...)", because it comes from a computed style's color value
24+
const reHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
25+
const reRgb = /^rgb\([^{}'";:]+\)$/;
26+
return reHex.test(s) || reRgb.test(s);
27+
}
28+
1829
const url = new URL(window.location.href);
1930

2031
const isDarkTheme = url.searchParams.get('gitea-is-dark-theme') === 'true';
@@ -23,7 +34,7 @@ if (isDarkTheme) {
2334
}
2435

2536
const backgroundColor = url.searchParams.get('gitea-iframe-bgcolor');
26-
if (backgroundColor) {
37+
if (isValidCssColor(backgroundColor)) {
2738
// create a style element to set background color, then it can be overridden by the content page's own style if needed
2839
const style = document.createElement('style');
2940
style.textContent = `
@@ -75,3 +86,7 @@ if (iframeId) {
7586
}
7687
});
7788
}
89+
90+
if (window.testModules) {
91+
window.testModules.externalRenderHelper = {isValidCssColor};
92+
}

web_src/js/features/common-page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@ export function checkAppUrl() {
156156
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
157157
return;
158158
}
159-
showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting.
160-
Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
159+
showGlobalErrorMessage(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
160+
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
161161
}
162162

163163
export function checkAppUrlScheme() {

web_src/js/globals.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ interface Window {
6868
turnstile: any,
6969
hcaptcha: any,
7070

71+
// Make IIFE private functions can be tested in unit tests, without exposing the IIFE module to global scope.
72+
// Otherwise, when using "export" in IIFE code, the compiled JS will inject global "var externalRenderHelper = ..."
73+
// which is not expected and may cause conflicts with other modules.
74+
testModules: {
75+
externalRenderHelper?: {
76+
isValidCssColor(s: string | null): boolean,
77+
}
78+
}
79+
7180
// do not add more properties here unless it is a must
7281
}
7382

web_src/js/vitest.setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,6 @@ window.config = {
2424
i18n: {},
2525
};
2626

27+
window.testModules = {};
28+
2729
export {}; // mark as module for top-level await

0 commit comments

Comments
 (0)