Skip to content

Commit 97f15a9

Browse files
authored
Merge branch 'dev' into migrate-gha
2 parents dc9b6e9 + 029fb8f commit 97f15a9

7 files changed

Lines changed: 89 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
## Added
88
- [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search
9+
- Added `csrf_token_name` and `csrf_header_name` config options to allow configuring the CSRF cookie and header names. Fixes [#729](https://github.com/plotly/dash/issues/729)
910

1011
## Added
1112
- [#3523](https://github.com/plotly/dash/pull/3523) Fall back to background callback function names if source cannot be found

dash/dash-renderer/src/actions/api.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,22 @@ import {JWT_EXPIRED_MESSAGE, STATUS} from '../constants/constants';
77
/* eslint-disable-next-line no-console */
88
const logWarningOnce = once(console.warn);
99

10-
function GET(path, fetchConfig) {
10+
function GET(path, fetchConfig, _body, config) {
1111
return fetch(
1212
path,
1313
mergeDeepRight(fetchConfig, {
1414
method: 'GET',
15-
headers: getCSRFHeader()
15+
headers: getCSRFHeader(config)
1616
})
1717
);
1818
}
1919

20-
function POST(path, fetchConfig, body = {}) {
20+
function POST(path, fetchConfig, body = {}, config) {
2121
return fetch(
2222
path,
2323
mergeDeepRight(fetchConfig, {
2424
method: 'POST',
25-
headers: getCSRFHeader(),
25+
headers: getCSRFHeader(config),
2626
body: body ? JSON.stringify(body) : null
2727
})
2828
);
@@ -55,7 +55,12 @@ export default function apiThunk(endpoint, method, store, id, body) {
5555
let res;
5656
for (let retry = 0; retry <= MAX_AUTH_RETRIES; retry++) {
5757
try {
58-
res = await request[method](url, config.fetch, body);
58+
res = await request[method](
59+
url,
60+
config.fetch,
61+
body,
62+
config
63+
);
5964
} catch (e) {
6065
// fetch rejection - this means the request didn't return,
6166
// we don't get here from 400/500 errors, only network

dash/dash-renderer/src/actions/callbacks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ function handleServerside(
483483
}
484484

485485
const fetchCallback = () => {
486-
const headers = getCSRFHeader() as any;
486+
const headers = getCSRFHeader(config) as any;
487487
let url = `${urlBase(config)}_dash-update-component`;
488488
let newBody = body;
489489

dash/dash-renderer/src/actions/index.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,16 @@ export function hydrateInitialOutputs() {
6161
/* eslint-disable-next-line no-console */
6262
const logWarningOnce = once(console.warn);
6363

64-
export function getCSRFHeader() {
64+
export function getCSRFHeader(config) {
6565
try {
66-
return {
67-
'X-CSRFToken': cookie.parse(document.cookie)._csrf_token
68-
};
66+
const tokenName = (config && config.csrf_token_name) || '_csrf_token';
67+
const headerName = (config && config.csrf_header_name) || 'X-CSRFToken';
68+
const cookies = cookie.parse(document.cookie);
69+
const token = cookies[tokenName];
70+
if (!token) {
71+
return {};
72+
}
73+
return {[headerName]: token};
6974
} catch (e) {
7075
logWarningOnce(e);
7176
return {};

dash/dash-renderer/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type DashConfig = {
2222
serve_locally?: boolean;
2323
plotlyjs_url?: string;
2424
validate_callbacks: boolean;
25+
csrf_token_name?: string;
26+
csrf_header_name?: string;
2527
};
2628

2729
export default function getConfigFromDOM(): DashConfig {

dash/dash.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,15 @@ class Dash(ObsoleteChecker):
421421
:param health_endpoint: Path for the health check endpoint. Set to None to
422422
disable the health endpoint. Default is None.
423423
:type health_endpoint: string or None
424+
425+
:param csrf_token_name: Name of the cookie to read the CSRF token from.
426+
Default ``'_csrf_token'``. Set this to match the CSRF cookie name
427+
used by your server framework (e.g. ``'csrftoken'`` for Django).
428+
:type csrf_token_name: string
429+
430+
:param csrf_header_name: Name of the HTTP header to send the CSRF token in.
431+
Default ``'X-CSRFToken'``.
432+
:type csrf_header_name: string
424433
"""
425434

426435
_plotlyjs_url: str
@@ -472,6 +481,8 @@ def __init__( # pylint: disable=too-many-statements
472481
on_error: Optional[Callable[[Exception], Any]] = None,
473482
use_async: Optional[bool] = None,
474483
health_endpoint: Optional[str] = None,
484+
csrf_token_name: str = "_csrf_token",
485+
csrf_header_name: str = "X-CSRFToken",
475486
**obsolete,
476487
):
477488

@@ -492,6 +503,11 @@ def __init__( # pylint: disable=too-many-statements
492503

493504
_validate.check_obsolete(obsolete)
494505

506+
if not csrf_token_name or not csrf_token_name.strip():
507+
raise ValueError("csrf_token_name must be a non-empty string")
508+
if not csrf_header_name or not csrf_header_name.strip():
509+
raise ValueError("csrf_header_name must be a non-empty string")
510+
495511
caller_name: str = name if name is not None else get_caller_name()
496512

497513
# We have 3 cases: server is either True (we create the server), False
@@ -545,6 +561,8 @@ def __init__( # pylint: disable=too-many-statements
545561
description=description,
546562
health_endpoint=health_endpoint,
547563
hide_all_callbacks=False,
564+
csrf_token_name=csrf_token_name,
565+
csrf_header_name=csrf_header_name,
548566
)
549567
self.config.set_read_only(
550568
[
@@ -555,6 +573,8 @@ def __init__( # pylint: disable=too-many-statements
555573
"serve_locally",
556574
"compress",
557575
"pages_folder",
576+
"csrf_token_name",
577+
"csrf_header_name",
558578
],
559579
"Read-only: can only be set in the Dash constructor",
560580
)
@@ -938,6 +958,8 @@ def _config(self):
938958
"ddk_version": ddk_version,
939959
"plotly_version": plotly_version,
940960
"validate_callbacks": self._dev_tools.validate_callbacks,
961+
"csrf_token_name": self.config.csrf_token_name,
962+
"csrf_header_name": self.config.csrf_header_name,
941963
}
942964
if self._plotly_cloud is None:
943965
if os.getenv("DASH_ENTERPRISE_ENV") == "WORKSPACE":

tests/unit/test_configs.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,47 @@ def test_debug_mode_enable_dev_tools(empty_environ, debug_env, debug, expected):
480480
def test_missing_flask_compress_raises():
481481
with pytest.raises(ImportError):
482482
Dash(compress=True)
483+
484+
485+
def test_csrf_config_defaults():
486+
app = Dash()
487+
assert app.config.csrf_token_name == "_csrf_token"
488+
assert app.config.csrf_header_name == "X-CSRFToken"
489+
490+
config = app._config()
491+
assert config["csrf_token_name"] == "_csrf_token"
492+
assert config["csrf_header_name"] == "X-CSRFToken"
493+
494+
495+
def test_csrf_config_custom():
496+
app = Dash(csrf_token_name="csrftoken", csrf_header_name="X-CSRF-Token")
497+
assert app.config.csrf_token_name == "csrftoken"
498+
assert app.config.csrf_header_name == "X-CSRF-Token"
499+
500+
config = app._config()
501+
assert config["csrf_token_name"] == "csrftoken"
502+
assert config["csrf_header_name"] == "X-CSRF-Token"
503+
504+
505+
def test_csrf_config_in_index():
506+
app = Dash(csrf_token_name="csrftoken")
507+
config_html = app._generate_config_html()
508+
assert '"csrf_token_name":"csrftoken"' in config_html
509+
assert '"csrf_header_name":"X-CSRFToken"' in config_html
510+
511+
512+
@pytest.mark.parametrize(
513+
"token_name, header_name",
514+
[("", "X-CSRFToken"), ("csrftoken", ""), (" ", "X-CSRFToken")],
515+
)
516+
def test_csrf_config_validation(token_name, header_name):
517+
with pytest.raises(ValueError):
518+
Dash(csrf_token_name=token_name, csrf_header_name=header_name)
519+
520+
521+
def test_csrf_config_read_only():
522+
app = Dash()
523+
with pytest.raises(AttributeError):
524+
app.config.csrf_token_name = "something_else"
525+
with pytest.raises(AttributeError):
526+
app.config.csrf_header_name = "something_else"

0 commit comments

Comments
 (0)