diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..1fca211
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,679 @@
+Palette - self-hosted pastebin with paste cans
+Copyright (C) 2026 poslop
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, version 3 of the License.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+SPDX-License-Identifier: AGPL-3.0-only
+
+--------------------------------------------------------------------------------
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index 1a17b0c..caaeb78 100644
--- a/README.md
+++ b/README.md
@@ -77,10 +77,41 @@ curl -X POST http://localhost:8080/api/pastes \
-d '{"content": "print(hello)", "language": "python", "expires_in": "168h"}'
```
-Full API docs: [docs/API.md](docs/API.md).
+Full API docs: [docs/API.md](docs/API.md). Design docs: [docs/design/](docs/design/) (currently: [client-side E2E encryption](docs/design/e2e-encryption.md), issue #39).
+
+## Performance Notes
+
+The history and Saved pages use client-side filtering: when you type in the
+search box, the UI fetches the most recent 100 pastes (`limit=100`, the API
+maximum) once per query and filters/sorts them in the browser. Pastes beyond
+the newest 100 are not searched; a match count against the full total is still
+shown. This keeps search instant without a server-side query. If large
+instances need full search later, it will be a server-side endpoint (see
+issue #32).
## CI
Gitea Actions workflow at `.gitea/workflows/ci.yml`:
- On push to main: `go vet` + `go test`
- On tags: build and push Docker image to `git.archfox.org/poslop/palette`
+
+## Docker Compose
+
+See [docker-compose.yml](docker-compose.yml) for a ready-to-use example with
+every environment variable documented, including which are required (only the
+/data volume) and which are optional.
+
+## License
+
+Palette is licensed under the GNU Affero General Public License v3.0
+(AGPL-3.0-only). See [LICENSE](LICENSE).
+
+The software is provided AS IS, without warranty of any kind, express or
+implied, including merchantability and fitness for a particular purpose.
+In no event shall the authors be liable for any claim, damages, or other
+liability, including without limitation any security vulnerabilities,
+data loss, or legal issues arising from use of the software. You use it
+at your own risk.
+
+If you run a modified version of Palette as a network service, the AGPL
+requires you to offer your modified source code to its users.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..068f90a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,46 @@
+# Example docker-compose deployment for Palette.
+# All environment variables are optional; sensible defaults apply.
+# The only hard requirement is a volume on /data so the SQLite database
+# and the auto-generated admin key survive restarts.
+
+services:
+ palette:
+ image: git.archfox.org/poslop/palette:v0.2.2
+ # image: git.archfox.org/poslop/palette:latest # tracks main, less stable
+ restart: unless-stopped
+ ports:
+ - "8080:8080" # host:container; the app listens on :8080
+ volumes:
+ - palette-data:/data # REQUIRED: SQLite db, admin key, attachments
+ environment:
+ # Address the server binds to inside the container.
+ # Default: ":8080". Only change if you also change the ports mapping.
+ PALETTE_ADDR: ":8080"
+
+ # Path to the SQLite database file.
+ # Default: "/data/palette.db". Keep it on the /data volume.
+ PALETTE_DB: "/data/palette.db"
+
+ # Admin API key for /admin/api/settings (rate limits, size caps, expiry).
+ # Default: random key generated on first start and persisted to
+ # /data/admin-key (mode 0600). Read it with:
+ # docker compose exec palette cat /data/admin-key
+ # Set this only if you want a fixed key (e.g. for automation).
+ # PALETTE_ADMIN_KEY: "change-me"
+
+ # Max size in bytes of a single text paste. Oversized creates get 413.
+ # Default: 5242880 (5 MiB).
+ # PALETTE_MAX_TEXT: "5242880"
+
+ # Max size in bytes of a single can item (file/text inside a can).
+ # Default: 26214400 (25 MiB).
+ # PALETTE_MAX_ITEM: "26214400"
+
+ # HMAC secret for password-unlock cookies. Default: random per start,
+ # which logs out every unlocked browser session on restart. Set a fixed
+ # secret (any random string) to keep unlock sessions across restarts,
+ # or when running multiple replicas that must agree.
+ # PALETTE_UNLOCK_SECRET: "generate-with-openssl-rand-base64-32"
+
+volumes:
+ palette-data:
diff --git a/docs/API.md b/docs/API.md
index f6ada59..efe29d7 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -22,6 +22,10 @@ curl -X POST http://localhost:8080/api/pastes \
- `expires_in` is a Go duration string (`90m`, `6h`, `336h`). Omit for no expiry.
- `visibility` is `public` or `unlisted`.
+- Alternatively (or additionally), `public` may be sent as a boolean (#83):
+ `false` maps to `unlisted` and `true` maps to `public`. When both fields are
+ present, the boolean `public` takes precedence over the string `visibility`.
+ Omitting both defaults to `public`.
- `burn_after_reads` sets how many reads the paste survives (default 1 when
`burn_after_read` is true). A read is counted per unique viewer session;
the same viewer returning within 15 minutes does not count again.
diff --git a/docs/design/attachments-storage.md b/docs/design/attachments-storage.md
new file mode 100644
index 0000000..8ffd61d
--- /dev/null
+++ b/docs/design/attachments-storage.md
@@ -0,0 +1,78 @@
+# Attachments & Storage Backend Design (#38, #31)
+
+Status: research/design, no implementation. Consumers: paste cans (#4).
+
+## Part A — Attachments: S3/MinIO vs filesystem-on-volume (#38)
+
+### Options
+
+**Option 1: Filesystem on the k3s PVC (current 5Gi volume).**
+Store blobs under `/attachments//-`, metadata in SQLite (paste_id, filename, size, sha256, mime, created_at).
+
+- Pros: zero new infra, zero new credentials, trivial backup (the volume backup job already covers the DB), atomic rename on write, works in dev and prod identically.
+- Cons: volume is size-capped (5Gi today; resizable but bounded); serving large files passes through the app process (no ranged-GET offload); multi-replica later would need RWX volume.
+
+**Option 2: MinIO via S3 API.**
+MinIO is already proven in this homelab (Outline). Store at key `/`; same SQLite metadata row.
+
+- Pros: effectively unbounded capacity, presigned URLs (direct browser download, offloads serving from palette pods), ranged requests free, lifecycle rules could auto-expire orphaned objects.
+- Cons: another credential/secret to manage, another failure mode, MultipartForm still terminates at the palette pod (MinIO only helps *serving*, not *uploading*, unless we do presigned uploads — which breaks the cans multipart flow and the auth/unlock checks), backup now spans two systems.
+
+### Key considerations
+
+- **Upload path is the same either way.** Palette receives `multipart/form-data` (cans need text items + file drops in one request), must enforce auth/password/burn rules server-side. A filesystem backend adds no upload complexity; S3 adds an extra hop (buffer → PUT to MinIO). Streaming straight from `multipart.Reader` to the sink works for both (`io.Copy` to a temp file, or to an S3 PUT with `Content-Length` known or multipart buffering).
+- **Size limits.** Everything is admin-tunable via the settings API (#40 pattern) — add `max_attachment_bytes` (default 10 MiB, hard server-side cap checked *before* reading the body via `Content-Length`, plus a counted reader during copy so chunked uploads can't lie). SQLite itself is not a constraint either way; the PVC is the real cap for Option 1.
+- **MIME handling.** Security-critical (pentest #34 already fixed a content-type XSS on `/raw`). Rules:
+ - Never trust the client-declared Content-Type. Sniff the first 512 bytes (`http.DetectContentType`), intersect with an allowlist.
+ - Serve from a dedicated route (`/{id}/a/{n}`) with `Content-Type` from the stored *sniffed* type, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`, `Content-Disposition: attachment` unless the type is on a safe-inline allowlist (text/plain, images, PDF at user opt-in).
+ - Never render user HTML/SVG inline (`image/svg+xml` is XSS-capable — serve as `attachment` always, or store sanitized).
+- **Streaming & serving.**
+ - Filesystem: `http.ServeContent` on the opened file gives ranged GETs, ETag, Last-Modified for free.
+ - MinIO: proxy via `GetObject` + `io.Copy` (simple, keeps auth checks in palette) or presigned GET (faster, but URL embeds credentials-temporarily and bypasses palette's per-request auth — wrong for pastes with passwords/burn semantics). Given cans inherit password/burn parity (#4), **proxying is required, which erodes MinIO's main serving advantage**.
+- **Lifecycle parity.** Attachments must honor soft-delete grace and sweep: sweeper hard-delete also removes blobs (files: `os.Remove`; S3: `DeleteObject`), best-effort with logging; orphan sweep job compares DB rows to store contents.
+
+### Recommendation
+
+**Filesystem-on-volume first.** At current scale (single replica, PVC-based deploy, one user + homelab traffic) it is simpler end-to-end and keeps serving/auth/lifecycle in one place. The internal API should be a narrow blob interface (`Put(ctx, key, r io.Reader, size int64) / Open(key) / Delete(key)`) — about 60 lines per backend — so **MinIO becomes a drop-in later** if attachments outgrow the volume. That's the honest middle path: filesystem default, S3-ready seam, no MinIO dependency until it pays for itself.
+
+## Part B — SQLite vs Postgres vs Redis (#31)
+
+### Assessment of SQLite at pastebin scale
+
+- **Driver**: modernc.org/sqlite (pure Go, no cgo) — slightly slower than mattn/go-sqlite3 but fine; single-writer semantics are the real constraint, not driver speed.
+- **Access pattern**: paste-heavy, write-rare/read-often; primary keys and small set of indexes (visibility+created, expires, deleted); no joins beyond can items. This is SQLite's best case.
+- **WAL mode** is already on (`journal_mode(WAL), busy_timeout(5000)`) — concurrent readers don't block the single writer.
+- **Numbers**: SQLite comfortably handles millions of rows and hundreds of reads/sec; WAL write throughput is thousands of small inserts/sec. A pastebin doing even 100k pastes (avg 10 KB = ~1 GB DB) is trivial. Reads: prepared `WHERE id=?` lookups at this size are sub-millisecond.
+- **Weak points to watch** (document, none urgent):
+ 1. Single writer — heavy concurrent create traffic serializes. Mitigation: already rate-limited (#2); fine until that's the bottleneck (unlikely).
+ 2. `LENGTH(content)` on every list row — fine now; if it shows up in profiling, store `size` as a column (schema already has a `Size` field; list queries could use it).
+ 3. Sweeper runs a table-wide `UPDATE`+`DELETE` on tick — indexed, fine.
+ 4. No network access to the DB file — locks palette to single-replica. Acceptable: current deploy is one replica.
+- **Redis is the wrong tool** here: it's a cache/queue, not a system of record. Pastes are durable data with expiry semantics already implemented in SQLite. Redis would only add an optional read-cache layer for hot pastes — pure complexity for zero measured need.
+
+### Should we build a backend abstraction (SQLite default, optional Postgres)?
+
+Arguments for: multi-replica scaling later; "docker image env choice" sounds nice; Postgres gives real concurrency and network access.
+Arguments against: a `Store` interface covering the current query surface is a real refactor (sqlite-flavored SQL: `INSERT OR IGNORE`-style upserts, partial indexes, `?` placeholders are compatible but behaviors differ — e.g. `sqlite` driver pragmas, transaction isolation, `AUTOINCREMENT` semantics); two backends means two test matrices and two migration paths forever; and there is **no current need** — single replica, single writer, modest data.
+
+**Recommendation: stay SQLite-only. Do not build the abstraction now.**
+Specifically:
+
+1. Keep all persistence behind `internal/store` (already done in #35 — the package boundary *is* the abstraction, at zero cost).
+2. Avoid SQLite-specific SQL going forward where free (standard placeholders, no `RETURNING` quirks) — cheap discipline that keeps a future port honest.
+3. Define the trigger conditions for revisiting, and write them down:
+ - multiple replicas needed (scale-out), or
+ - sustained WAL write contention (busy timeouts observed in logs), or
+ - DB file > ~5-10 GB, or
+ - a concrete user request for a Postgres-backed image.
+4. When a trigger fires, port `internal/store` to Postgres behind an interface extracted *then* — the refactor is mechanical against a real need, instead of speculative complexity now.
+5. For the docker image: `PALETTE_DB_PATH` env already implies the deployment choice; no extra backend knob needed.
+
+## Summary
+
+| Decision | Choice |
+|---|---|
+| Attachment backend | Filesystem on PVC, behind a ~3-method blob interface; MinIO as a later drop-in, not a dependency |
+| Size limits | `max_attachment_bytes` admin setting, default 10 MiB, enforced pre-read + during stream |
+| MIME | Server-side sniff (512 bytes) + allowlist; `nosniff`, CSP `sandbox`, `Content-Disposition: attachment` except safe-inline types; SVG never inline |
+| Database | SQLite (WAL, modernc) only; no Postgres/Redis, no backend abstraction until a written trigger fires |
diff --git a/docs/design/cookie-preferences.md b/docs/design/cookie-preferences.md
new file mode 100644
index 0000000..74eeae4
--- /dev/null
+++ b/docs/design/cookie-preferences.md
@@ -0,0 +1,160 @@
+# Design: Cookie-Based Preferences and Access Keys (#30)
+
+Status: design note — no implementation yet.
+Related: #37 (vwr viewer cookie), #34 (HMAC unlock cookie), #26 (creator auto-unlock), #36 (settings gear).
+
+## Current cookie surface
+
+| Cookie | Purpose | Lifetime | Flags today |
+|---|---|---|---|
+| `vwr` | Anonymous viewer id; scopes `/mine` history and burn-after-N per-viewer dedupe; client-sent `vwr` also authorizes delete | 1 year | `HttpOnly`, `SameSite=Lax`, `Path=/` |
+| `pw_` | Per-paste password unlock token = HMAC(paste id, PALETTE_UNLOCK_SECRET) | 1 hour | `HttpOnly`, `SameSite=Lax`, `Path=/` |
+| `tok_` | One-time deletion-token handoff after create | 60 s | `HttpOnly`, `SameSite=Lax`, `Path=/` |
+
+The access-key feature is an extension of the `pw_` pattern, not a new mechanism.
+
+## Part 1: Preference storage
+
+### What settings
+
+Only settings the *creator* sets when writing a paste, so the "new paste" form
+can pre-fill them:
+
+- Default language (`lang`)
+- Default expiry (`expires_in` / custom expiry)
+- Burn-after-N-reads default
+- Password-protect-by-default toggle (checkbox pre-checked; the password itself is never stored)
+- Default visibility of the "raw" link, if such a toggle exists
+- Collapsed/expanded state of the settings gear panel itself
+
+Never stored in cookies: passwords, access keys for pastes the user hasn't
+unlocked, deletion tokens (beyond the existing 60 s `tok_` handoff), anything
+typed into the paste body or title fields (existing rule: auto-detect must not
+overwrite user-typed content).
+
+### One cookie, not many
+
+A single `prefs` cookie holding a compact JSON object:
+
+```
+prefs={"lang":"go","exp":"1h","burn":0,"pw":1}
+```
+
+- One cookie avoids the browser per-domain cookie count (typically 50+ per
+ domain; Chrome 180) eating the budget that per-paste access-key cookies need.
+- Per-paste cookies (`pw_`) are inherently name-per-paste and cannot be
+ consolidated — that's the constraint that makes a single `prefs` cookie
+ mandatory rather than stylistic.
+
+### Size limits
+
+- RFC 6265: user agents SHOULD support at least 4096 bytes per cookie. Keep
+ `prefs` under 256 bytes of JSON — it holds a handful of short enum values.
+- Server behavior: if the cookie is present but oversized/invalid JSON, ignore
+ it silently and serve defaults. Never reject a request over a bad preference
+ cookie.
+- Validate on the server (allowlist of known values); a cookie is untrusted
+ input like any header.
+
+### Flags
+
+`HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000; Secure` (Secure once the
+prod instance serves HTTPS — it will, behind the letsencrypt IngressRoute;
+dev on plain HTTP needs the flag conditional on config).
+
+Preferences are not sensitive, but `HttpOnly` costs nothing and keeps script
+from mutating them; `SameSite=Lax` matches the existing cookies.
+
+## Part 2: Access-key cookies
+
+### Goal
+
+"Remember unlocked pastes on this browser" — after entering a password (or
+after creating a private paste), subsequent visits skip the unlock form. This
+extends `pw_` from a 1-hour session convenience to a durable capability.
+
+### Design: extend `pw_`, don't invent a new scheme
+
+The token is already HMAC(paste id, PALETTE_UNLOCK_SECRET) — unforgeable and
+per-paste (fix for the #34 bypass). Changes:
+
+1. **Opt-in checkbox on the unlock form** ("remember on this browser") and a
+ matching checkbox/note at creation time. Default OFF. Non-consenting
+ visitors keep the current 1-hour cookie.
+2. **Extended lifetime** when opted in: `Max-Age = min(paste expiry, 90 days)`.
+ The cookie must never outlive the paste — derive the cap from the paste's
+ `ExpiresAt` at unlock time. Burn-after-N pastes: cap short (e.g. 24 h),
+ since the paste may burn at any read.
+3. **Name collision**: paste ids are fixed-length server-generated, so
+ `pw_` names stay bounded (~40 bytes each). With the 50-cookies-per-
+ domain budget, cap remembered pastes at ~30: when minting the 31st, drop
+ the oldest expired-paste cookies server-side (server knows which ids are
+ expired/deleted; send expired `Set-Cookie` with `Max-Age=0` to reclaim).
+4. **Delete authorization interplay**: today a client-sent `vwr` matching the
+ paste's ViewerID authorizes delete. Access-key cookies grant *read*
+ capability only. Do not let a `pw_` cookie authorize deletion — that
+ would mean cookie theft escalates from "read a paste" to "destroy it".
+ Delete stays bound to `vwr` or the deletion token.
+
+### Scoping
+
+- Keep `Path=/` (paste URLs are `/{id}` at the root; per-paste `Path=/{id}`
+ would work but saves nothing and complicates cleanup).
+- Per-paste scope via the cookie *name* is the existing, tested pattern —
+ no shared "access key ring" cookie. A consolidated `keys` cookie would
+ mean one stolen cookie exposes every remembered paste at once.
+
+## Part 3: Security considerations (honest accounting)
+
+- **XSS exfiltration**: `HttpOnly` prevents JS from *reading* the cookies, but
+ not from *using* them — an XSS payload can simply `fetch('/')` and
+ exfiltrate the content through the page the cookie unlocks. HttpOnly raises
+ the bar (drive-by script can't dump the jar to an attacker server in one
+ request), it does not make access-key cookies safe. This is a real
+ limitation, not a solved problem. Mitigations in order of value:
+ 1. Fix the stored-XSS class at the source — #34 already allowlisted
+ content-types on `/raw`; the standing debt items (CSP, X-Frame-Options,
+ Referrer-Policy) directly reduce cookie-use exfiltration and should land
+ before or with this feature.
+ 2. Keep access-key cookies opt-in, so the blast radius is bounded to users
+ who accepted the tradeoff.
+- **Cookie theft = paste access**: anyone holding `pw_` can read that
+ paste until the cookie or paste expires, from any machine. That is inherent
+ to capability cookies. Consequences accepted deliberately: pastes here are
+ ephemeral (1 min–1 yr expiry), passwords are low-stakes share convenience,
+ and there are no user accounts to compromise. Document this in the UI copy
+ ("stores unlock access on this browser").
+- **Shared machines**: a remembered cookie defeats the password for the next
+ user of the browser. The opt-in checkbox with plain-language copy is the
+ mitigation; do not default it on.
+- **Cookie tossing / fixation**: a subdomain attacker could try to force
+ cookies; palette is a single host, no untrusted subdomains. `SameSite=Lax`
+ blocks cross-site attachment of the cookies on form posts to unlock
+ endpoints.
+- **Multi-instance / secret rotation**: tokens are HMACs under
+ `PALETTE_UNLOCK_SECRET`; rotating the secret silently invalidates all
+ remembered cookies (acceptable — next visit re-prompts). Both dev and prod
+ k3s instances need the same secret only if sharing a domain, which they do
+ not.
+- **Preferences cookie**: not security-sensitive, but still validate/allowlist
+ server-side to avoid it becoming an injection sink into templates.
+
+## Recommendation
+
+Implement in two small, separately reviewable pieces:
+
+1. **`prefs` cookie** (do first, low risk): single JSON cookie < 256 bytes,
+ server-validated allowlist, `HttpOnly; SameSite=Lax; Max-Age=1y`, drives
+ only form pre-fill. Ship with #36's settings gear.
+2. **Extended `pw_` opt-in** (second, security-sensitive): opt-in checkbox,
+ Max-Age capped by paste expiry (90-day ceiling, 24 h for burn pastes),
+ oldest-cookie eviction at ~30 pastes, no delete authorization from access
+ cookies, and land the CSP/X-Frame-Options hardening debt from #34 in the
+ same or preceding change. UI copy must disclose that the cookie preserves
+ paste access on the browser.
+
+Rejected alternatives: single consolidated access-key cookie (aggregate theft
+risk, and the per-domain cookie-count argument cuts the other way for keys —
+consolidation maximizes what one stolen cookie unlocks); localStorage for
+preferences (XSS-readable, no benefit over HttpOnly cookies here); server-side
+accounts/session table (out of scope — Palette is deliberately anonymous).
diff --git a/docs/design/e2e-encryption.md b/docs/design/e2e-encryption.md
new file mode 100644
index 0000000..d1c76cc
--- /dev/null
+++ b/docs/design/e2e-encryption.md
@@ -0,0 +1,223 @@
+# Design: Optional client-side E2E encryption for pastes and files
+
+- **Issue:** #39
+- **Status:** Design (no implementation in this PR)
+- **Related docs:** [docs/API.md](../API.md)
+
+## 1. Goals and non-goals
+
+**Goals**
+
+- Let any paste (or can item) be stored server-side as ciphertext only.
+- Zero plaintext knowledge by the server: storage, logs, backups, DB dumps contain no readable content.
+- Pure browser implementation using WebCrypto; no new server dependencies.
+- Encrypted pastes must still work with expiry, hard/soft delete, deletion tokens, visibility, slugs, rate limits.
+
+**Non-goals (v1)**
+
+- Anonymous, account-less E2E; Palette stays server-trusting with browser cookies.
+- Sharing via link fragments (`#key`) is optional sugar, not a required transport.
+- Search *of encrypted content*, server-side language detection, or server-side highlighting on encrypted pastes — these are structurally impossible and out of scope (see §5).
+- Signing, deniability, forward secrecy across pastes, PFS, post-quantum crypto.
+
+**Threat model (explicit).** This protects against a *passive server compromise* — a DB dump, backup leak, or disk image of the server's SQLite file. It does **not** protect against:
+
+- A fully malicious / compromised Palette server serving backdoored JavaScript: any JS-delivered crypto can be backdoored (key exfiltration via JS) regardless of primitives. This is the fundamental limit of a JS-in-browser E2E scheme.
+- Malware on the viewer's device, or shoulder-surfing of the password.
+- Traffic analysis, timing, or metadata (title, size, expiry, IP, viewer cookie).
+- A attacker who compromises the server *while the creator's browser is open* and alters JS before encrypt.
+
+Be explicit in user-facing copy: "encrypted at rest; the server cannot read your paste" is accurate — "the server can never see your paste" is not.
+
+## 2. Crypto primitives and flow
+
+### 2.1 Recommended parameters
+
+| Parameter | Recommendation | Notes |
+|---|---|---|
+| Cipher | **AES-256-GCM** | `AES-GCM` with a 256-bit key, per-paste random 96-bit IV/nonce. WebCrypto built-in, hardware-accelerated, authenticated. |
+| KDF | **PBKDF2-HMAC-SHA-256** | 600,000 iterations (OWASP 2023+ recommendation), 16-byte random salt. |
+| Argon2id | **Not in v1** | WebCrypto has no Argon2id; a JS/WASM Argon2 implementation is an extra supply-chain dependency and is an asymmetric liability: a script the server could swap can't be load-bearing for security anyway. Add later via `argon2id` WASM with SRI pinning + CSP (`script-src 'self'`) if needed. |
+| Salt | 16 random bytes per paste, stored in the clear alongside ciphertext | Unique per paste, never reused. |
+| IV | 12 random bytes per encryption | With ~2^32 encryptions per key this is negligible; each paste has its own key anyway. |
+| Key check value | See §2.2 | Catches wrong passwords without a server round-trip and prevents trash writes. |
+
+### 2.2 Flow (create)
+
+1. User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in `/new`.
+2. Browser generates `salt` (16 B) and `iv` (12 B) via `crypto.getRandomValues`.
+3. `crypto.subtle.importKey("raw", passphrase, "PBKDF2", false, ["deriveKey"])` →
+ `crypto.subtle.deriveKey(PBKDF2-SHA-256, 600k iterations, salt, {name:"AES-GCM", length:256}, false, ["encrypt","decrypt"])`.
+4. Generate a 32-byte random **DEK** (`crypto.getRandomValues(32)`).
+5. Content encryption key check: `iv_ckv`, `encrypted_content = AES-GCM-256(DEK, iv, content)`.
+6. **Key check value (KCV):** compute `AES-GCM-DEK(random 16 bytes)` — a small token encrypted *under the DEK*, stored as `key_check` blob. This is decrypted with the derived key; on wrong password GCM auth fails and the client can show "wrong key" without asking the server to burn a read.
+7. Wrap the DEK with the KEK: `wrapped_dek = AES-GCM(KEK, iv_wrap, dek)`.
+8. The stored envelope format:
+ ```
+ {
+ v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64,
+ kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64
+ ```
+ The `v` field allows migrating to Argon2id later without a breaking change.
+8. POST the envelope (base64) as `content`, with an `encryption` metadata object alongside (see §3.1).
+
+### 2.3 Flow (view)
+
+1. User provides the passphrase via form field, or the key arrives in the URL `#fragment`. The encryption passphrase is a separate field from any access password.
+2. Fetch `/api/pastes/{id}` (with access password in the usual field if the paste is also password-gated).
+3. Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step.
+4. Decrypt content with the DEK; on `OperationError` → "wrong passphrase" UI state (retries are client-side only; no re-fetch, so no extra burn-after-read charge).
+5. Language detection happens client-side (e.g. highlight.js auto-detect) on the decrypted plaintext.
+
+### §2.4 File and can items
+
+Files in cans: encrypt each file with its own DEK and store the same envelope. Cans' `json_items` content fields each carry their own envelope. Files keep their mime type in cleartext metadata; only the bytes are encrypted. The can's title stays plaintext (unless the whole can is encrypted, v2).
+
+## 3. API shapes
+
+### 3.1 Create request
+
+Existing fields unchanged. New optional `encryption` object:
+
+```json
+POST /api/pastes
+{
+ "content": "",
+ "encryption": {"v": 1, "kdf": "PBKDF2-SHA256", "iterations": 600000,
+ "salt": "b64", "iv": "b64", "key_check": "b64"}
+}
+```
+
+`encryption` is non-secret KDF metadata for UI display; the server treats `content` as opaque bytes and MUST NOT inspect it for encrypted pastes (no detection, no highlighting prep, no search indexing) — enforced where content is written, not per-handler.
+
+The full envelope can also just live inside `content` (server-opaque); the `encryption` object carries only non-secret KDF metadata the list views need (e.g. to show a 🔒 icon).
+
+### 3.2 Create response
+
+Unchanged shape: `id`, `url`, `raw_url`, `api_url`, and the one-time `deletion_token` documented in docs/API.md.
+
+### 3.3 Get response
+
+`GET /api/pastes/{id}` response gains:
+
+```json
+{
+ "id": "abc123",
+ "content": "BASE64_ENVELOPE",
+ "encryption": {"v":1, "kdf": "PBKDF2-SHA256", "iterations": 601570, "salt": "b64", "iv": "base64", "key_check": "b64"},
+ "reads_remaining": null
+}
+```
+
+`language` is `"encrypted"` or `null` so clients don't run detection on ciphertext. `raw_url` also serves the envelope; the `/{id}` page ships it to the browser, which decrypts in place.
+
+### 3.4 Raw endpoint
+
+`GET /raw/{id}` returns the envelope as `application/octet-stream` with a suggested filename like `{id}.e2e.txt` and `Content-Disposition: attachment`. This is deliberate: a "download encrypted blob" is what a non-browser client can do with it anyway.
+
+### 3.5 List views / mine / public
+
+List endpoints return `has_encryption: true` instead of content; show a lock icon. Do not include ciphertext in list responses (size, and no reason to ship ciphertext to every viewer's list view) — `GET /api/pastes/{id}` remains the only endpoint that returns the envelope.
+
+`/api/mine` (creator's own browser) may include the envelope for convenience; `/api/public` returns metadata only.
+
+`/api/guess-language` rejects encrypted content with `400 "content is client-encrypted"` — detection needs plaintext; clients detect after decrypting.
+
+Delete, redeem, rate limits, expiry, sweeper, deletion tokens, visibility, slugs, and can CRUD are unchanged — the server never inspects content for these, so opaque content is a no-op path.
+
+## 4. Interplay with existing features
+
+| Feature | Impact | Mitigation |
+| Burn-after-read | Budget is charged on fetch, exactly as today; the server cannot know whether decryption succeeded, so a viewer fetching with the wrong key burns a read they can't use. | Decrypt retries are client-side, so only the first fetch charges the budget. Clear UX copy. |
+| Password-protected + encrypted | Both can coexist and are independent: the access password is an HTTP 401 gate; the encryption passphrase never leaves the browser. If both are set, all three secrets are needed (URL + access password + passphrase). Warn if the user enters the same value in both fields. |
+| Encryption-only pastes | Supported with no access password: URL + passphrase (or fragment key). Default is passphrase; fragment key is opt-in with a warning. |
+| Search | Structurally impossible over ciphertext. Server search just skips encrypted pastes; client-side search within a single decrypted paste works fine. No global encrypted-content search — accept the loss, document it. |
+| Language detection / highlighting | Server-side detection/highlighting impossible; returns `language: null`. Client-side detection via highlight.js auto-detect on decrypted plaintext (client already loads it for password gate pages). |
+| Cans/files | Per-item envelopes (own DEK each), per §2.4. | Consider a can-level KEK (one passphrase unlocks all items). |
+| Expiry/sweeper/delete/redeem | Unchanged — server never inspects content for these. |
+| List views (`/api/public`, `/api/mine`) | Additive `has_encryption: true` flag; list responses do not include ciphertext (`/api/mine` may include the envelope for the creator's own convenience). |
+| guess-language endpoint | Reject with 400. |
+| Fork / edit | Re-encryption needs the passphrase in the browser; v1 disables forking encrypted pastes. | Document the limitation. |
+
+## 5. What breaks, stated plainly
+
+- **Search across encrypted pastes: impossible.** Accept the loss. (If ever needed, client-side index in IndexedDB for the creator's own pastes — v2+.)
+ IndexedDB only helps the creator, not other viewers; still not global search. Accept the loss.
+- **Server-side language detection and highlighting: impossible.** Client-side detection on decrypted plaintext. Server returns `language: null` and the client detects.
+- **Burn-after-read is weakened in one specific way:** the budget is counted on fetch, not on successful decryption. A viewer who fetches but can't decrypt (wrong/lost key) burns a read they can't use. Mitigations documented in §4 table. The server can still count fetches (which is what burn-after-read actually is, even today: it counts fetches, not "reads" in any content-aware sense). So burn-after-read still works — it counts fetches — it's just that a failed decryption still consumes budget. This is acceptable and just needs UX copy. Optionally: don't decrement on failed decryption is *not possible* the server can't tell, so it's fetch-based, period. (It already is today.)
+- **Raw endpoint semantics change:** `/raw/{id}` can no longer serve readable raw text. It serves the ciphertext envelope. Scripts that curl raw pastes will get base64 envelope instead of text. Document as a breaking-ish change for encrypted pastes only; unencrypted pastes unchanged.
+- **Existing /api/mine, /api/public list shapes gain a flag** (additive, non-breaking).
+- **Copy-to-clipboard of decrypted text stays client-side**, fine. "Copy raw" on an encrypted paste copies the envelope — label it clearly.
+
+## 5. UX for key sharing
+### 5.1 Three sharing modes
+
+| Mode | What's shared | Security level | Use case |
+|---|---| malformed JSON / wrong key | | |
+| Mode | What's shared | Strength | Use case |
+|---|---|---|---|
+| **Passphrase** (default) | URL + passphrase out-of-band (Signal etc.) | Good — two channels | Team snippets, sensitive configs |
+| **Passphrase + access password** | URL + access password (401 gate) + passphrase | Strong — two secrets, two channels | Highest sensitivity |
+| **Random key in `#fragment`** | URL containing `#key=` | Weak — single channel; anyone with the full URL has both parts. Copy/paste into chat defeats it entirely. | One-click convenience sharing |
+
+Browsers never transmit `#` fragments to servers; still set `Referrer-Policy: no-referrer` site-wide and offer separate copy buttons for URL and key. Key-in-fragment ships with a warning and stays opt-in.
+
+### 5.2 Create page (`/new`) UX
+
+- "Encrypt content" toggle → reveals passphrase field + strength meter + generate-random-key button.
+- When encrypting, hide the server-side language dropdown; the client detects language after decryption.
+- Two separate inputs with distinct labels: "Access password (checked by the server, 401 gate)" and "Encryption passphrase (never leaves your browser)". If both hold the same value, warn.
+
+### 6.2 View page (`/{id}`) UX
+
+- If `encryption.kdf` is present → show key entry UI (after the access-password 401 gate, if that also applies).
+- After decrypt: normal render pipeline, language detected client-side.
+- "Wrong passphrase" retries never re-fetch, so they never burn extra reads.
+
+## 7. Backwards compatibility and migration
+
+- Additive JSON fields only; unencrypted pastes behave identically. No schema changes (envelope is stored in the existing content column/TEXT; verify column size allows envelope overhead (~2× base64 + ~200 B header).
+- Server-side validation of encrypted pastes: only structural checks (base64 decodes, size ≤ max bytes). No crypto in the server.
+
+**Server implementation cost is genuinely small** (est. 2-4 days): pass through content untouched, add `encryption` metadata column or embed in content, skip detection/indexing when `encryption` is present, list flag. The server never does crypto. All crypto is client-side JS (~150-300 lines, no build-step change if using WebCrypto alone).
+
+**Argon2id later:** add `kdf: "argon2id"` to the envelope `v: 1` (m=64 MiB, t=3, p=1) via a SRI-pinned WASM module, with CSP `script-src 'self'` + SRI on the script tag. Envelope `v` field already allows this.
+
+## 8. Recommendation
+
+**Build it, as an opt-in checkbox, passphrase mode only in v1.**
+
+- Server cost is small (pass-through + skip detection/indexing + list flag), client cost moderate (WebCrypto only, no new deps).
+- It closes the biggest real-world risk for a public pastebin: a DB/backup leak exposing every paste ever written.
+- Skip Argon2id in v1; envelope `v` field provides a migration path.
+- Key-in-fragment mode: build the plumbing (fragment parsing) but hide behind "advanced"; default remains passphrase.
+
+**Do not build:** server-side search over encrypted content, server-side highlighting of encrypted content, decrypt-on-server "preview" mode, or any server-side crypto.
+
+## 9. Open questions
+
+1. Size limits: base64 expansion (~4/3×) plus ~200 B envelope overhead; the existing max-bytes / 413 limit applies to the envelope bytes the server stores. Do not compress before encrypting (CRIME-style weaknesses).
+2. Fork/edit of encrypted pastes: disabled in v1, revisit.
+3. Should `/api/mine` include the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload.
+4. Should there be a "verify passphrase" second field at create time (type-twice), or rely on the KCV check at view time? KCV at view time suffices; type-twice adds friction at create. Rely on KCV, skip type-twice.
+5. CSP/Referrer-Policy hardening: `Referrer-Policy: no-referrer` site-wide is worth doing regardless of this feature (it also benefits unencrypted pastes).
+6. Cans: per-item DEKs wrapped by a single can-level KEK (one passphrase unlocks all items) — better UX, slightly more envelope design work. Defer detail to implementation.
+
+## 10. Alternatives considered
+
+| Alternative | Why not in v1 |
+|---|---|
+| Argon2id via WASM in v1 | Extra JS dependency the server could swap → can't be load-bearing; PBKDF2-600k is adequate for a pastebin. Defer. |
+| Server holds half a key (2-of-2 with server-held share) | Re-introduces server trust; defeats the purpose. |
+| age-format envelopes | Nice CLI interop but no WebCrypto-native support; adds a JS dependency. Defer. |
+| PGP / S-MIME | Poor browser UX; heavy dependencies. |
+| Server-side encryption with server-held keys | Not E2E; that's "encrypted at rest", already covered by disk-level encryption. |
+| PrivateBin-style fragment key only | Single-channel sharing is a footgun; keep passphrase as default. |
+| libsodium / tweetnacl | Solid but unnecessary; WebCrypto covers AES-GCM + PBKDF2 natively. |
+
+## 11. References
+
+- OWASP Password Storage Cheat Sheet (PBKDF2 guidance): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
+- MDN WebCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
+- PrivateBin (prior art for fragment-key sharing): https://privatebin.info
+- 0bin, Hemmelig — other pastebin/secret E2E prior art.
diff --git a/internal/api/admin.go b/internal/api/admin.go
index bdde0da..b88d9dc 100644
--- a/internal/api/admin.go
+++ b/internal/api/admin.go
@@ -172,6 +172,11 @@ func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+ if !rateLimitAdmin(r) {
+ log.Printf("admin auth RATE LIMITED: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
+ writeRateLimited(w, 60)
+ return
+ }
if !a.adminKeyOK(r, key) {
log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeErr(w, 401, "unauthorized")
diff --git a/internal/api/adminratelimit_test.go b/internal/api/adminratelimit_test.go
new file mode 100644
index 0000000..677ce4b
--- /dev/null
+++ b/internal/api/adminratelimit_test.go
@@ -0,0 +1,80 @@
+package api
+
+// #66: admin key attempts must be rate limited per IP (5/min), constant-time
+// compared, and failures logged. Hammering bad keys must yield 429s.
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// TestAdminKeyRateLimited: burst of 5 bad-key attempts allowed (401), the 6th
+// gets 429, and even the correct key is blocked from that IP until refill.
+func TestAdminKeyRateLimited(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ reqIP := "10.7.7.1:1234"
+
+ var got429, retryAfter bool
+ var lastCode int
+ for i := 0; i < 10; i++ {
+ req := httptest.NewRequest("POST", "/admin/api/settings", nil)
+ req.RemoteAddr = reqIP
+ req.Header.Set("X-Admin-Key", "wrong-key")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ lastCode = rec.Code
+ if rec.Code == 429 {
+ got429 = true
+ retryAfter = rec.Header().Get("Retry-After") != ""
+ break
+ }
+ }
+ if !got429 {
+ t.Fatalf("expected 429 after hammering bad keys, last status %d", lastCode)
+ }
+ if !retryAfter {
+ t.Error("429 missing Retry-After header")
+ }
+
+ // Correct key from the same IP is also locked out.
+ req := httptest.NewRequest("POST", "/admin/api/settings", nil)
+ req.RemoteAddr = reqIP
+ req.Header.Set("X-Admin-Key", srv.adminKey)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 429 {
+ t.Errorf("correct key after lockout: got %d, want 429", rec.Code)
+ }
+
+ // A different IP is unaffected.
+ req2 := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(`{"rate_limit_burst":5,"rate_limit_per_minute":60,"max_content_bytes":1048576,"custom_slug_reservation_days":30,"burn_viewer_window_minutes":15}`))
+ req2.RemoteAddr = "203.0.113.9:1234"
+ req2.Header.Set("X-Admin-Key", srv.adminKey)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req2)
+ if rec2.Code != 200 {
+ t.Errorf("correct key from another IP: got %d, want 200", rec2.Code)
+ }
+}
+
+// TestAdminKeyConstantTimeCompare: sanity check that the comparison is
+// constant-time (uses subtle.ConstantTimeCompare, not ==).
+func TestAdminKeyConstantTimeCompare(t *testing.T) {
+ srv := newTestServer(t)
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set("X-Admin-Key", "test-admin-key")
+ if !srv.adminKeyOK(r, srv.adminKey) {
+ t.Fatal("correct key rejected")
+ }
+ r.Header.Set("X-Admin-Key", "wrong")
+ if srv.adminKeyOK(r, srv.adminKey) {
+ t.Fatal("wrong key accepted")
+ }
+ // differ in length: must not panic/mismatch unexpectedly
+ r.Header.Set("X-Admin-Key", "test-admin-key-longer")
+ if srv.adminKeyOK(r, srv.adminKey) {
+ t.Fatal("longer wrong key accepted")
+ }
+}
diff --git a/internal/api/cans_expiry_test.go b/internal/api/cans_expiry_test.go
new file mode 100644
index 0000000..3b549d0
--- /dev/null
+++ b/internal/api/cans_expiry_test.go
@@ -0,0 +1,45 @@
+package api
+
+import (
+ "net/http/httptest"
+ "testing"
+)
+
+// #60: the cans API must clamp expires_in at the boundary exactly like the
+// pastes API — reject zero/negative durations and anything over the 1-year
+// UI cap, accept the exact boundaries.
+func TestCreateCanExpiryBounds(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ cases := []struct {
+ expiresIn string
+ wantCode int
+ }{
+ {"-1h", 400}, // negative
+ {"-0s", 400}, // negative zero
+ {"0s", 400}, // zero
+ {"1ns", 400}, // positive but below the 1-minute floor
+ {"59s", 400}, // just under the floor
+ {"1m", 201}, // exactly the floor
+ {"90s", 201}, // just over the floor
+ {"8760h", 201}, // exactly 1 year
+ {"8785h", 400}, // 1 year + 1 day: over the cap
+ {"87600h", 400}, // 10 years, the originally reported case
+ }
+ for _, c := range cases {
+ globalLimiter = newLimiter() // avoid create rate limit between cases
+ body, ct := multipartBody(t, map[string]string{
+ "json_items": `[{"title":"a.txt","content":"AAA"}]`,
+ "expires_in": c.expiresIn,
+ }, "files", "pic.txt", "file data")
+ req := httptest.NewRequest("POST", "/api/pastes/can", body)
+ req.Header.Set("Content-Type", ct)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != c.wantCode {
+ t.Errorf("expires_in %q: got %d want %d (%s)",
+ c.expiresIn, rec.Code, c.wantCode, rec.Body.String())
+ }
+ }
+}
diff --git a/internal/api/issue68_validation_test.go b/internal/api/issue68_validation_test.go
new file mode 100644
index 0000000..9f3e0e7
--- /dev/null
+++ b/internal/api/issue68_validation_test.go
@@ -0,0 +1,198 @@
+package api
+
+// Regression tests for #68 input-validation gaps: negative/oversized content
+// lengths (413), limit=0 → default page size, unchecked query params
+// (negative offset), and negative burn_after_reads.
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func createPasteRaw(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// A request whose decoded content exceeds the admin-tunable cap is rejected
+// with 413 and a clear message (content under the body cap, over the
+// content cap).
+func TestCreatePasteContentOverCap413(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ // content over MaxContentBytes (5MiB) but under body cap (+4KiB): send
+ // just over the content cap so the per-field check fires first.
+ content := strings.Repeat("a", 5*1024*1024+10)
+ rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s"}`, content))
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "content exceeds max") {
+ t.Fatalf("unclear error message: %s", rec.Body.String())
+ }
+}
+
+// A request whose entire body is larger than the server-side body cap is cut
+// off by http.MaxBytesReader and answered with 413, not decoded into memory
+// (#68: previously a giant body was fully buffered, then rejected only at
+// the per-field check — actually the decode happened before any check).
+func TestCreatePasteBodyOverCap413(t *testing.T) {
+ s := testServer(t)
+ // shrink the content cap so the body cap is small too
+ ss := s.settings.get()
+ ss.MaxContentBytes = 64 * 1024
+ if err := s.settings.set(ss); err != nil {
+ t.Fatal(err)
+ }
+ h := s.routes()
+
+ content := strings.Repeat("a", 200*1024) // 200KiB > 64KiB+4KiB body cap
+ rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"%s","title":"x"}`, content))
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("got %d want 413: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// Negative content lengths cannot be expressed via JSON, but a negative
+// expires-style numeric payload must not crash; more importantly the
+// burn_after_reads field: negative values are rejected with a clear message.
+func TestCreatePasteNegativeBurnAfterReads(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ rec := createPasteRaw(t, h, `{"content":"hi","burn_after_reads":-5}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "burn_after_reads") {
+ t.Fatalf("unclear error message: %s", rec.Body.String())
+ }
+
+ // 0 and positive values still work (0 = default single read, per #49)
+ rec = createPasteRaw(t, h, `{"content":"hi","burn_after_reads":0,"burn_after_read":true}`)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("zero burn_after_reads: got %d want 201: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// limit=0 on list endpoints returns the default page size (existing clamp
+// treats <=0 as default; #68 asks this be explicit and tested).
+func TestListLimitZeroUsesDefault(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ // seed 3 public pastes
+ for i := 0; i < 3; i++ {
+ rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
+ if rec.Code != 201 {
+ t.Fatalf("seed: got %d: %s", rec.Code, rec.Body.String())
+ }
+ }
+
+ for _, q := range []string{"/api/public?limit=0", "/api/public"} {
+ req := httptest.NewRequest("GET", q, nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("%s: got %d", q, rec.Code)
+ }
+ var got struct {
+ Limit int `json:"limit"`
+ Total int `json:"total"`
+ Items []struct{ ID string } `json:"items"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if got.Limit != 25 || len(got.Items) != 3 {
+ t.Fatalf("%s: limit=%d items=%d, want limit 25 and all 3 items", q, got.Limit, len(got.Items))
+ }
+ }
+}
+
+// Huge limit values are clamped to the max page size (already the behavior;
+// regression-tested here per #68 "validate unchecked params").
+func TestListLimitHugeClamped(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ req := httptest.NewRequest("GET", "/api/public?limit=999999999", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var got struct {
+ Limit int `json:"limit"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if got.Limit != 25 {
+ t.Fatalf("limit=%d, want clamped to 25", got.Limit)
+ }
+}
+
+// Negative offset previously passed through unchecked to SQL (harmless in
+// SQLite, but invalid); it must be clamped to 0 (#68).
+func TestListNegativeOffsetClamped(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ for i := 0; i < 2; i++ {
+ rec := createPasteRaw(t, h, fmt.Sprintf(`{"content":"p%d"}`, i))
+ if rec.Code != 201 {
+ t.Fatalf("seed: got %d", rec.Code)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/public?offset=-999", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("got %d", rec.Code)
+ }
+ var got struct {
+ Offset int `json:"offset"`
+ Total int `json:"total"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if got.Offset != 0 || got.Total != 2 {
+ t.Fatalf("offset=%d total=%d, want offset 0 and total 2", got.Offset, got.Total)
+ }
+
+ // /api/mine too (needs the viewer cookie)
+ req = httptest.NewRequest("GET", "/api/mine?offset=-5", nil)
+ req.AddCookie(&http.Cookie{Name: "vwr", Value: "offclamp"})
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var mine struct {
+ Offset int `json:"offset"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &mine)
+ if mine.Offset != 0 {
+ t.Fatalf("mine offset=%d, want 0", mine.Offset)
+ }
+}
+
+// Non-numeric limit/offset fall back to defaults instead of 500s.
+func TestListGarbageParams(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ req := httptest.NewRequest("GET", "/api/public?limit=abc&offset=xyz", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("got %d", rec.Code)
+ }
+ var got struct {
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ if got.Limit != 25 || got.Offset != 0 {
+ t.Fatalf("limit=%d offset=%d, want 25/0", got.Limit, got.Offset)
+ }
+}
diff --git a/internal/api/issue81_password_ratelimit_test.go b/internal/api/issue81_password_ratelimit_test.go
new file mode 100644
index 0000000..f33d91f
--- /dev/null
+++ b/internal/api/issue81_password_ratelimit_test.go
@@ -0,0 +1,92 @@
+package api
+
+// #81: ALL password verification attempts (GET query param, header, POST
+// form) must go through the per-IP unlock limiter. Regression: N wrong
+// passwords via GET ?password= must eventually yield 429.
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
+ t.Helper()
+ h := s.routes()
+ body := `{"content":"secret","password":"` + pw + `"}`
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var created struct {
+ ID string `json:"id"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &created)
+ return created.ID
+}
+
+// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
+// ?password= must eventually return 429 (unlock limiter: burst 5).
+func TestRateLimitGetPasswordQuery(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ id := createPasswordPaste(t, s, "hunter2")
+
+ var saw429 bool
+ // more attempts than the unlock burst (5)
+ for i := 0; i < 10; i++ {
+ req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code == 429 {
+ saw429 = true
+ break
+ }
+ if rec.Code != 401 {
+ t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
+ }
+ }
+ if !saw429 {
+ t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one")
+ }
+}
+
+// TestRateLimitGetPasswordHeader: same guarantee for the X-Paste-Password header path.
+func TestRateLimitGetPasswordHeader(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ id := createPasswordPaste(t, s, "hunter2")
+
+ var saw429 bool
+ for i := 0; i < 10; i++ {
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code == 429 {
+ saw429 = true
+ break
+ }
+ if rec.Code != 401 {
+ t.Fatalf("attempt %d: expected 401 before limit, got %d", i, rec.Code)
+ }
+ }
+ if !saw429 {
+ t.Fatal("expected 429 after repeated wrong header password attempts, never got one")
+ }
+}
+
+// TestRateLimitGetPasswordCorrectStillAllowed: a correct password must still
+// work within the burst (the limiter gates attempts, not correctness).
+func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ id := createPasswordPaste(t, s, "hunter2")
+
+ req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("expected 200 for correct password within burst, got %d", rec.Code)
+ }
+}
diff --git a/internal/api/issue86_field_bounds_test.go b/internal/api/issue86_field_bounds_test.go
new file mode 100644
index 0000000..8e97d16
--- /dev/null
+++ b/internal/api/issue86_field_bounds_test.go
@@ -0,0 +1,152 @@
+package api
+
+// Regression tests for #86: title and language are bounded at create time.
+// Titles over 200 chars are truncated; language must match
+// ^[a-zA-Z0-9+#-]{1,40}$ or the create is rejected with a clear 400.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// pasteMeta fetches a created paste's stored metadata via the API.
+func pasteMeta(t *testing.T, h http.Handler, id string) map[string]any {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/pastes/"+id, nil))
+ if rec.Code != 200 {
+ t.Fatalf("get paste %s: got %d: %s", id, rec.Code, rec.Body.String())
+ }
+ var m map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+// A 5000-char title is truncated to 200 characters at create time (#86).
+func TestCreatePasteTitleTruncated(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ title := strings.Repeat("t", 5000)
+ body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
+ rec := createPasteRaw(t, h, string(body))
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ ID string `json:"id"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ meta := pasteMeta(t, h, resp.ID)
+ got, _ := meta["title"].(string)
+ if got != strings.Repeat("t", 200) {
+ t.Fatalf("title not truncated to 200 chars: len=%d", len(got))
+ }
+}
+
+// A title within the 200-char bound is stored verbatim (minus surrounding
+// whitespace, which is trimmed).
+func TestCreatePasteTitleWithinBoundKept(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ title := " " + strings.Repeat("x", 200) + " "
+ body, _ := json.Marshal(map[string]any{"content": "hi", "title": title})
+ rec := createPasteRaw(t, h, string(body))
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("got %d want 201: %s", rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ ID string `json:"id"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ meta := pasteMeta(t, h, resp.ID)
+ if got, _ := meta["title"].(string); got != strings.Repeat("x", 200) {
+ t.Fatalf("title changed unexpectedly: len=%d", len(got))
+ }
+}
+
+// A language longer than 40 chars is rejected with a clear 400 (#86).
+func TestCreatePasteLanguageTooLong400(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ body, _ := json.Marshal(map[string]any{"content": "hi", "language": strings.Repeat("a", 41)})
+ rec := createPasteRaw(t, h, string(body))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("got %d want 400: %s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "language") {
+ t.Fatalf("unclear error message: %s", rec.Body.String())
+ }
+}
+
+// Language strings outside ^[a-zA-Z0-9+#-]{1,40}$ are rejected with 400.
+func TestCreatePasteLanguageBadFormat400(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ for _, bad := range []string{
+ "
",
+ "java script",
+ "c++ extra!",
+ "py_thon",
+ "go.lang",
+ } {
+ body, _ := json.Marshal(map[string]any{"content": "hi", "language": bad})
+ rec := createPasteRaw(t, h, string(body))
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("language %q: got %d want 400: %s", bad, rec.Code, rec.Body.String())
+ continue
+ }
+ if !strings.Contains(rec.Body.String(), "language must match") {
+ t.Errorf("language %q: unclear error: %s", bad, rec.Body.String())
+ }
+ }
+}
+
+// Valid languages (letters, digits, #, +, -) within 40 chars are accepted.
+func TestCreatePasteLanguageValidAccepted(t *testing.T) {
+ for _, ok := range []string{"go", "c#", "f#", "c++", "objective-c", "ECMAScript-2023", strings.Repeat("a", 40)} {
+ s2 := testServer(t) // fresh rate limiter per case
+ h2 := s2.routes()
+ body, _ := json.Marshal(map[string]any{"content": "hi", "language": ok})
+ rec := createPasteRaw(t, h2, string(body))
+ if rec.Code != http.StatusCreated {
+ t.Errorf("language %q: got %d want 201: %s", ok, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+// An absent or blank language still creates fine and stores NULL, and a
+// blank title is stored NULL rather than an empty string.
+func TestCreatePasteBlankMetadataOK(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ for _, body := range []string{
+ `{"content":"hi"}`,
+ `{"content":"hi","language":"","title":" "}`,
+ } {
+ rec := createPasteRaw(t, h, body)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("body %s: got %d want 201: %s", body, rec.Code, rec.Body.String())
+ }
+ var resp struct {
+ ID string `json:"id"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ meta := pasteMeta(t, h, resp.ID)
+ if lang, ok := meta["language"]; ok && lang != nil && lang != "" {
+ t.Fatalf("body %s: language not null: %v", body, lang)
+ }
+ if title, ok := meta["title"]; ok && title != nil && title != "" {
+ t.Fatalf("body %s: title not null: %v", body, title)
+ }
+ }
+}
diff --git a/internal/api/main_test.go b/internal/api/main_test.go
index 8220d29..55011d2 100644
--- a/internal/api/main_test.go
+++ b/internal/api/main_test.go
@@ -183,6 +183,46 @@ func TestListPublicExcludesUnlisted(t *testing.T) {
}
}
+func TestListPublicExcludesPasswordAndUnlisted(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ bodies := []string{
+ `{"content":"open","visibility":"public"}`,
+ `{"content":"locked","visibility":"public","password":"hunter2"}`,
+ `{"content":"hidden","visibility":"unlisted"}`,
+ }
+ for _, body := range bodies {
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 201 {
+ t.Fatalf("create %s: got %d", body, rec.Code)
+ }
+ }
+
+ req := httptest.NewRequest("GET", "/api/public", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("list public: got %d", rec.Code)
+ }
+ var resp struct {
+ Total int `json:"total"`
+ Items []map[string]any `json:"items"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ if resp.Total != 1 || len(resp.Items) != 1 {
+ t.Fatalf("expected only the 1 public paste, got total=%d items=%d", resp.Total, len(resp.Items))
+ }
+ // password-protected and unlisted pastes must not appear (no metadata leak)
+ for _, secret := range []string{"hunter2", "locked", "hidden"} {
+ if strings.Contains(rec.Body.String(), secret) {
+ t.Fatalf("leaked %q in /api/public response", secret)
+ }
+ }
+}
+
func TestSweepSoftDeletesAfterGrace(t *testing.T) {
s := testServer(t)
h := s.routes()
diff --git a/internal/api/publicbool_test.go b/internal/api/publicbool_test.go
new file mode 100644
index 0000000..b2320b6
--- /dev/null
+++ b/internal/api/publicbool_test.go
@@ -0,0 +1,97 @@
+package api
+
+// #83 regression tests: `public` boolean in the create payload must map to
+// visibility (false -> unlisted, true -> public); string `visibility` still works.
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func createPasteBody(t *testing.T, h *apiServer, body string) map[string]any {
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.routes().ServeHTTP(rec, req)
+ if rec.Code != 201 {
+ t.Fatalf("create: got %d: %s", rec.Code, rec.Body.String())
+ }
+ var resp map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ return resp
+}
+
+func getVis(t *testing.T, h *apiServer, id string) string {
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ rec := httptest.NewRecorder()
+ h.routes().ServeHTTP(rec, req)
+ if rec.Code != 200 {
+ t.Fatalf("get %s: got %d", id, rec.Code)
+ }
+ var resp struct {
+ Visibility string `json:"visibility"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("bad json: %v", err)
+ }
+ return resp.Visibility
+}
+
+// TestPublicBooleanFalseMapsToUnlisted: {"public": false} must create an unlisted paste.
+func TestPublicBooleanFalseMapsToUnlisted(t *testing.T) {
+ s := testServer(t)
+ resp := createPasteBody(t, s, `{"content":"x","public":false}`)
+ if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
+ t.Fatalf("public:false -> got visibility %q, want unlisted", v)
+ }
+}
+
+// TestPublicBooleanTrueMapsToPublic: {"public": true} must create a public paste.
+func TestPublicBooleanTrueMapsToPublic(t *testing.T) {
+ s := testServer(t)
+ resp := createPasteBody(t, s, `{"content":"x","public":true}`)
+ if v := getVis(t, s, resp["id"].(string)); v != "public" {
+ t.Fatalf("public:true -> got visibility %q, want public", v)
+ }
+}
+
+// TestPublicBooleanOverridesString: boolean wins when both fields are sent.
+func TestPublicBooleanOverridesString(t *testing.T) {
+ s := testServer(t)
+ resp := createPasteBody(t, s, `{"content":"x","visibility":"public","public":false}`)
+ if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
+ t.Fatalf("boolean override -> got %q, want unlisted", v)
+ }
+}
+
+// TestVisibilityStringStillWorks: existing string contract unchanged.
+func TestVisibilityStringStillWorks(t *testing.T) {
+ s := testServer(t)
+ resp := createPasteBody(t, s, `{"content":"x","visibility":"unlisted"}`)
+ if v := getVis(t, s, resp["id"].(string)); v != "unlisted" {
+ t.Fatalf("string field -> got %q, want unlisted", v)
+ }
+ resp = createPasteBody(t, s, `{"content":"y","visibility":"public"}`)
+ if v := getVis(t, s, resp["id"].(string)); v != "public" {
+ t.Fatalf("string field -> got %q, want public", v)
+ }
+}
+
+// TestPublicListExcludesPublicFalse: {"public":false} pastes stay out of /api/public.
+func TestPublicListExcludesPublicFalse(t *testing.T) {
+ s := testServer(t)
+ createPasteBody(t, s, `{"content":"hidden","public":false}`)
+ req := httptest.NewRequest("GET", "/api/public", nil)
+ rec := httptest.NewRecorder()
+ s.routes().ServeHTTP(rec, req)
+ var resp struct {
+ Total int `json:"total"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ if resp.Total != 0 {
+ t.Fatalf("public:false paste leaked into /api/public: total=%d", resp.Total)
+ }
+}
diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go
index c31aec0..5533831 100644
--- a/internal/api/ratelimit.go
+++ b/internal/api/ratelimit.go
@@ -48,8 +48,28 @@ func (l *limiter) allow(key string, rate, burst float64) bool {
return true
}
-// clientIP extracts the request IP (no reverse proxy header by default).
+// clientIP extracts the client IP for rate-limit keying (#85).
+//
+// Trust boundary: palette runs behind exactly ONE trusted reverse proxy
+// (Traefik in the k3s pod network). Traefik APPENDS the real client IP to
+// X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted
+// proxy observed and is unspoofable by the client (a client-supplied fake
+// entry only lands on the LEFT and is ignored). This matches chi's
+// middleware.RealIP semantics for a single trusted proxy hop.
+//
+// Direct connections (no XFF header) fall back to RemoteAddr. Directly
+// reachable deployments must NOT expose the app to untrusted networks
+// without a proxy in front, or attackers could forge the rightmost entry.
func clientIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ if i := strings.LastIndex(xff, ","); i >= 0 {
+ return strings.TrimSpace(xff[i+1:])
+ }
+ return strings.TrimSpace(xff)
+ }
+ if xr := r.Header.Get("X-Real-Ip"); xr != "" {
+ return strings.TrimSpace(xr)
+ }
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
@@ -84,6 +104,12 @@ func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
}
+// rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66),
+// same pattern as the unlock limiter (#34).
+func rateLimitAdmin(r *http.Request) bool {
+ return globalLimiter.allow("admin:"+clientIP(r), 5.0/60.0, 5)
+}
+
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
diff --git a/internal/api/ratelimit_xff_test.go b/internal/api/ratelimit_xff_test.go
new file mode 100644
index 0000000..8674636
--- /dev/null
+++ b/internal/api/ratelimit_xff_test.go
@@ -0,0 +1,85 @@
+package api
+
+// Issue #85: the rate limit key must use the rightmost X-Forwarded-For entry
+// (appended by the trusted Traefik proxy), never the raw/leftmost header
+// value a client can forge. A spoofed FIRST XFF entry must not bypass the
+// limit or rotate buckets.
+
+import (
+ "bytes"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestClientIPTakesRightmostXFF(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", nil)
+ r.RemoteAddr = "10.42.0.7:51000" // trusted Traefik pod
+ r.Header.Set("X-Forwarded-For", "1.2.3.4, 1.2.3.5, 203.0.113.9")
+ if got := clientIP(r); got != "203.0.113.9" {
+ t.Fatalf("clientIP = %q, want rightmost 203.0.113.9", got)
+ }
+}
+
+func TestClientIPXRealIPFallback(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", nil)
+ r.RemoteAddr = "10.42.0.7:51000"
+ r.Header.Set("X-Real-Ip", "203.0.113.10")
+ if got := clientIP(r); got != "203.0.113.10" {
+ t.Fatalf("clientIP = %q, want 203.0.113.10", got)
+ }
+}
+
+func TestClientIPDirectFallback(t *testing.T) {
+ r := httptest.NewRequest("POST", "/", nil)
+ r.RemoteAddr = "198.51.100.5:51000"
+ if got := clientIP(r); got != "198.51.100.5" {
+ t.Fatalf("clientIP = %q, want 198.51.100.5", got)
+ }
+}
+
+// TestRateLimitSpoofedFirstXFFDoesNotBypass: an attacker rotating a fake
+// leftmost XFF entry stays limited on their real (rightmost) IP.
+func TestRateLimitSpoofedFirstXFFDoesNotBypass(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ for i := 0; i < 5; i++ {
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = "10.42.0.7:51000"
+ // each request spoofs a DIFFERENT leftmost entry
+ req.Header.Set("X-Forwarded-For", spoofN(i)+", 203.0.113.9")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 201 {
+ t.Fatalf("req %d: want 201, got %d", i, rr.Code)
+ }
+ }
+ // 6th request, still the same real IP, new spoofed prefix: must 429
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = "10.42.0.7:51000"
+ req.Header.Set("X-Forwarded-For", "9.9.9.9, 203.0.113.9")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 429 {
+ t.Fatalf("spoofed 6th req: want 429, got %d", rr.Code)
+ }
+}
+
+func spoofN(i int) string {
+ return "1.2.3." + string(rune('0'+i))
+}
+
+// Distinct real IPs must still get distinct buckets (no over-limiting).
+func TestRateLimitDistinctRightmostIPsIndependent(t *testing.T) {
+ srv := newTestServer(t)
+ h := srv.routes()
+ for _, ip := range []string{"203.0.113.20", "203.0.113.21"} {
+ req := httptest.NewRequest("POST", "/api/pastes", bytes.NewReader([]byte(`{"content":"hi"}`)))
+ req.RemoteAddr = "10.42.0.7:51000"
+ req.Header.Set("X-Forwarded-For", "6.6.6.6, "+ip)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != 201 {
+ t.Fatalf("ip %s: want 201, got %d", ip, rr.Code)
+ }
+ }
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 9e3de65..7c3b8f8 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -60,7 +60,9 @@ func (a *apiServer) routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
+ r.Use(a.limitRequestBody) // #68: hard server-side body cap -> 413
r.Use(viewerCookieMiddleware)
+ r.Use(web.SecurityHeaders) // #59: CSP + hardening headers on HTML pages
// admin (#40): HTML page is open (key entry via form); API is key-guarded
r.Get("/admin", a.ui.Handlers().HandleAdminPage)
@@ -155,16 +157,43 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
}
var p store.Paste
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+ if isBodyTooLarge(err) { // #68: body cut off by MaxBytesReader
+ writeBodyTooLarge(w)
+ return
+ }
writeErr(w, 400, "invalid json body")
return
}
- if strings.TrimSpace(p.Content) == "" {
- writeErr(w, 400, "content is required")
+ if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
+ writeErr(w, status, msg)
return
}
- if int64(len(p.Content)) > s.MaxContentBytes { // #40: admin-tunable
- writeErr(w, 413, fmt.Sprintf("content exceeds max %d bytes", s.MaxContentBytes))
- return
+ // #86: bound free-form metadata at create time
+ if p.Title != nil {
+ t, err := checkTitle(*p.Title)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ p.Title = &t
+ }
+ if p.Language != nil {
+ l, err := checkLanguage(*p.Language)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ if l == "" {
+ p.Language = nil
+ } else {
+ p.Language = &l
+ }
+ }
+ if p.BurnAfterReads != nil { // #68: reject negative read budgets
+ if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
}
// #40: admin-configurable default expiry
if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
@@ -209,6 +238,13 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return
}
if row.PasswordHash.Valid {
+ // #81: every password verification (header, query param, or empty)
+ // goes through the same per-IP+paste unlock limiter as the POST form
+ // path, so brute-force via GET ?password= or X-Paste-Password gets 429.
+ if !rateLimitUnlock(row.ID, r) {
+ writeRateLimited(w, 60)
+ return
+ }
// require password via header or query
pw := r.Header.Get("X-Paste-Password")
if pw == "" {
@@ -293,11 +329,8 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"total": 0, "items": []any{}})
return
}
- limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
- if limit <= 0 || limit > 100 {
- limit = 50
- }
- offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ limit := parseLimit(r, 50, 100)
+ offset := parseOffset(r)
rows, total, err := a.store.ListMine(vid, limit, offset)
if err != nil {
writeErr(w, 500, "db error")
@@ -317,11 +350,8 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) {
}
func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
- limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
- if limit <= 0 || limit > 100 {
- limit = 25
- }
- offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
+ limit := parseLimit(r, 25, 100)
+ offset := parseOffset(r)
rows, total, err := a.store.ListPublic(limit, offset)
if err != nil {
writeErr(w, 500, "db error")
diff --git a/internal/api/validate.go b/internal/api/validate.go
new file mode 100644
index 0000000..40c7cdb
--- /dev/null
+++ b/internal/api/validate.go
@@ -0,0 +1,136 @@
+package api
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// #68 input-validation helpers. Paste/can payloads are size-capped and list
+// endpoints get a single place where limit/offset are parsed and clamped.
+
+// maxRequestBody returns the HTTP body cap for JSON create endpoints: the
+// admin-tunable content cap plus headroom for JSON field overhead, floored
+// at 64KiB so a tiny admin-configured cap can't break small requests.
+func (a *apiServer) maxRequestBody() int64 {
+ s := a.settings.get()
+ max := s.MaxContentBytes + 4096
+ if max < 64*1024 {
+ max = 64 * 1024
+ }
+ return max
+}
+
+// limitRequestBody wraps the request body with http.MaxBytesReader so
+// oversized payloads are cut off server-side instead of being fully decoded
+// into memory before the per-field size check runs (#68). A read over the
+// cap surfaces as *http.MaxBytesError, which handlers map to 413.
+func (a *apiServer) limitRequestBody(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Body != nil {
+ r.Body = http.MaxBytesReader(w, r.Body, a.maxRequestBody())
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// writeBodyTooLarge emits the 413 response for a body rejected by
+// MaxBytesReader.
+func writeBodyTooLarge(w http.ResponseWriter) {
+ writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
+}
+
+// isBodyTooLarge reports whether err came from http.MaxBytesReader.
+func isBodyTooLarge(err error) bool {
+ var mbe *http.MaxBytesError
+ return errors.As(err, &mbe)
+}
+
+// checkContent validates paste content: rejects whitespace-only content
+// (400) and content over the byte cap (413). Returns (0, "") when valid.
+func checkContent(content string, maxBytes int64) (int, string) {
+ if strings.TrimSpace(content) == "" {
+ return http.StatusBadRequest, "content is required"
+ }
+ if int64(len(content)) > maxBytes { // #40/#68: admin-tunable cap
+ return http.StatusRequestEntityTooLarge,
+ fmt.Sprintf("content exceeds max %d bytes", maxBytes)
+ }
+ return 0, ""
+}
+
+// parseLimit clamps the ?limit query param: missing/non-numeric/zero/negative
+// or over-max values fall back to def. Zero intentionally maps to the default
+// page size, matching the pre-existing `<= 0` clamp (#68).
+func parseLimit(r *http.Request, def, max int) int {
+ n, err := strconv.Atoi(r.URL.Query().Get("limit"))
+ if err != nil || n <= 0 || n > max {
+ return def
+ }
+ return n
+}
+
+// parseOffset clamps the ?offset query param: missing/non-numeric or negative
+// values become 0 (#68: negative offsets previously passed through to SQL).
+func parseOffset(r *http.Request) int {
+ n, err := strconv.Atoi(r.URL.Query().Get("offset"))
+ if err != nil || n < 0 {
+ return 0
+ }
+ return n
+}
+
+// parseBurnAfterReads validates the burn_after_reads field (#68): negative
+// values are rejected; zero/absent mean the default single read.
+func parseBurnAfterReads(n int) error {
+ if n < 0 {
+ return errors.New("burn_after_reads must be a positive number")
+ }
+ return nil
+}
+
+// #86: bounds for free-form metadata fields on create.
+const (
+ maxTitleLen = 200
+ maxLanguageLen = 40
+)
+
+// languageRe restricts language to identifiers like go, c#, f#, c++, objc.
+var languageRe = regexp.MustCompile(`^[a-zA-Z0-9+#-]{1,40}$`)
+
+// checkTitle validates the paste title (#86): over-max titles are truncated
+// to 200 characters so a bloated listing entry can't be stored; whitespace
+// is trimmed first.
+func checkTitle(title string) (string, error) {
+ title = strings.TrimSpace(title)
+ if len(title) > maxTitleLen {
+ return truncateRunes(title, maxTitleLen), nil
+ }
+ return title, nil
+}
+
+// checkLanguage validates the language field (#86): optional, max 40 chars,
+// and must match ^[a-zA-Z0-9+#-]{1,40}$. Returns "" for absent/blank values.
+// Anything else malformed is a 400.
+func checkLanguage(lang string) (string, error) {
+ lang = strings.TrimSpace(lang)
+ if lang == "" {
+ return "", nil
+ }
+ if len(lang) > maxLanguageLen || !languageRe.MatchString(lang) {
+ return "", fmt.Errorf("language must match ^[a-zA-Z0-9+#-]{1,40}$ (max %d chars)", maxLanguageLen)
+ }
+ return lang, nil
+}
+
+// truncateRunes cuts s to at most max runes, keeping the prefix intact.
+func truncateRunes(s string, max int) string {
+ runes := []rune(s)
+ if len(runes) <= max {
+ return s
+ }
+ return string(runes[:max])
+}
diff --git a/internal/web/securityheaders_test.go b/internal/web/securityheaders_test.go
new file mode 100644
index 0000000..b6d54c7
--- /dev/null
+++ b/internal/web/securityheaders_test.go
@@ -0,0 +1,46 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// #59: SecurityHeaders must add the CSP and hardening headers to rendered
+// HTML responses only; JSON and /raw responses pass through untouched.
+func TestSecurityHeaders(t *testing.T) {
+ pages := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte("ok"))
+ })
+ h := SecurityHeaders(pages)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
+ wantCSP := "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
+ if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
+ t.Errorf("CSP = %q, want %q", got, wantCSP)
+ }
+ if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
+ t.Errorf("Referrer-Policy = %q, want no-referrer", got)
+ }
+ if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
+ }
+
+ // JSON/raw responses: headers are now set unconditionally BEFORE the handler
+ // runs. The previous post-handler approach was silently dropped once a page
+ // handler flushed its template output (headers must be set before WriteHeader).
+ // CSP/nosniff/referrer on non-HTML bodies is harmless and desirable.
+ jsonh := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"ok":true}`))
+ }))
+ rec = httptest.NewRecorder()
+ jsonh.ServeHTTP(rec, httptest.NewRequest("GET", "/api/x", nil))
+ if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
+ t.Errorf("CSP missing on JSON response: got %q", got)
+ }
+ if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
+ t.Errorf("Referrer-Policy missing on JSON response: got %q", got)
+ }
+}
diff --git a/internal/web/static/table.js b/internal/web/static/table.js
index 971114c..7824080 100644
--- a/internal/web/static/table.js
+++ b/internal/web/static/table.js
@@ -63,7 +63,7 @@ const PaletteTable = (() => {
const filtered = state.filter.length > 0;
const off = (state.page - 1) * opts.perPage;
const url = (filtered || state.sortKey)
- ? opts.endpoint + '?limit=500&offset=0'
+ ? opts.endpoint + '?limit=' + (opts.fetchLimit || 100) + '&offset=0'
: opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off;
const res = await fetch(url);
const data = await res.json();
diff --git a/internal/web/web.go b/internal/web/web.go
index 2c8be3f..fb12054 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -328,3 +328,25 @@ func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
// Handlers builds a web.Handlers bound to this UI.
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }
+
+// #59: security headers for rendered HTML pages. Applied wherever the
+// response is text/html (page templates and the inline can page); JSON API
+// responses and /raw content pass through untouched. script-src allows
+// 'unsafe-inline' because the page templates carry inline scripts; CSP
+// default-src 'self' still blocks external content and object/frame embeds,
+// and frame-ancestors 'none' closes the clickjacking gap flagged in the #34
+// pentest. Runs after the handler so the Content-Type is already set.
+func SecurityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Set before the handler runs: once a handler writes (template render
+ // flushes), header mutations are silently dropped. Setting the headers
+ // unconditionally is safe: CSP/nosniff/referrer on JSON or /raw bodies
+ // is harmless and arguably desirable.
+ h := w.Header()
+ h.Set("Content-Security-Policy",
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; frame-ancestors 'none'")
+ h.Set("Referrer-Policy", "no-referrer")
+ h.Set("X-Content-Type-Options", "nosniff")
+ next.ServeHTTP(w, r)
+ })
+}